ticketlens 0.38.10 → 0.38.12
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/bin/ticketlens.mjs +4 -2
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +2 -2
- package/skills/jtb/scripts/fetch-my-tickets.mjs +2 -2
- package/skills/jtb/scripts/lib/activity-counter.mjs +46 -11
- package/skills/jtb/scripts/lib/help.mjs +15 -3
- package/skills/jtb/scripts/lib/mcp-server.mjs +6 -6
- package/skills/jtb/scripts/lib/note-command.mjs +5 -2
- package/skills/jtb/scripts/lib/recall-command.mjs +11 -5
- package/skills/jtb/scripts/lib/recall-settings-sync.mjs +23 -5
- package/skills/jtb/scripts/lib/recall-vault.mjs +30 -0
- package/skills/jtb/scripts/lib/run-stats.mjs +6 -7
- package/skills/jtb/scripts/lib/secret-scanner.mjs +38 -11
- package/skills/jtb/scripts/lib/ticket-command.mjs +22 -4
- package/skills/jtb/scripts/lib/triage-history.mjs +17 -1
- package/skills/jtb/scripts/lib/triage-push.mjs +3 -0
package/bin/ticketlens.mjs
CHANGED
|
@@ -262,11 +262,13 @@ switch (command) {
|
|
|
262
262
|
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printLicenseHelp(); break; }
|
|
263
263
|
const s = createStyler({ isTTY: process.stdout.isTTY });
|
|
264
264
|
const status = checkLicense();
|
|
265
|
+
const hasLicense = Boolean(status.key);
|
|
265
266
|
const daysSinceVal = status.validatedAt
|
|
266
267
|
? (Date.now() - new Date(status.validatedAt)) / 86400000
|
|
267
268
|
: Infinity;
|
|
268
|
-
// Grace period: treat as inactive if not revalidated within 30 days
|
|
269
|
-
|
|
269
|
+
// Grace period: treat as inactive if not revalidated within 30 days — only applies
|
|
270
|
+
// once a license has actually been activated (never conflate "never had one" with "lapsed").
|
|
271
|
+
const graceExpired = hasLicense && daysSinceVal > 30;
|
|
270
272
|
process.stdout.write('\n');
|
|
271
273
|
if (status.active && !graceExpired) {
|
|
272
274
|
process.stdout.write(` ${s.green('●')} ${s.bold('License active')}\n\n`);
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.33.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.
|
|
@@ -236,7 +236,7 @@ echo "The body text of the note, one or more paragraphs." | \
|
|
|
236
236
|
ticketlens note add --title="Short title" --ticket=TICKET-KEY --tags=a,b
|
|
237
237
|
```
|
|
238
238
|
|
|
239
|
-
**Choosing tags:** derive them from this note's actual content — the specific technology, error type, root cause, or affected component (e.g. `retry-backoff`, `null-pointer`, `auth-middleware`) — never the project name or a generic category word like `gotcha` or `bug`. A tag like `jtb` or `ticketlens` tells a future search nothing that the ticket/project context doesn't already say; a tag like `retry-backoff` is what actually surfaces this note when someone else hits the same problem. Same rule whether you're constructing the bash command above or calling `recall_add` directly — see its tool description for the same guidance.
|
|
239
|
+
**Choosing tags:** derive them from this note's actual content — the specific technology, error type, root cause, or affected component (e.g. `retry-backoff`, `null-pointer`, `auth-middleware`) — never the project name or a generic category word like `gotcha` or `bug`. A tag like `jtb` or `ticketlens` tells a future search nothing that the ticket/project context doesn't already say; a tag like `retry-backoff` is what actually surfaces this note when someone else hits the same problem. A tag that just restates the title in different words, or one you can't trace to a specific sentence in the body, gives that same zero signal — if you can't point to the exact phrase that justifies it, drop it. Same rule whether you're constructing the bash command above or calling `recall_add` directly — see its tool description for the same guidance.
|
|
240
240
|
|
|
241
241
|
To search saved notes directly (outside of automatic brief injection): `ticketlens recall "<query>"`.
|
|
242
242
|
|
|
@@ -440,7 +440,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
440
440
|
} catch { /* non-fatal */ }
|
|
441
441
|
|
|
442
442
|
// Track this triage run (best-effort, non-fatal)
|
|
443
|
-
try { incrementTriageRun(configDir); } catch { /* non-fatal */ }
|
|
443
|
+
try { incrementTriageRun(configDir, resolvedProfileName); } catch { /* non-fatal */ }
|
|
444
444
|
|
|
445
445
|
// --digest: POST scored results to the digest backend endpoint
|
|
446
446
|
if (digestFlag) {
|
|
@@ -500,7 +500,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
500
500
|
const cliToken = opts.cliToken ?? readCliToken(configDir) ?? null;
|
|
501
501
|
const printFn = opts.print ?? ((s) => process.stdout.write(s));
|
|
502
502
|
let cliActivity = null;
|
|
503
|
-
try { cliActivity = readAndResetActivity(configDir ?? DEFAULT_CONFIG_DIR); } catch { /* non-fatal */ }
|
|
503
|
+
try { cliActivity = readAndResetActivity(configDir ?? DEFAULT_CONFIG_DIR, resolvedProfileName); } catch { /* non-fatal */ }
|
|
504
504
|
// Server-side notify/schedule rules must see actively-worked ('clear') tickets too —
|
|
505
505
|
// only a ticket the user explicitly told the CLI to ignore is excluded from push.
|
|
506
506
|
const pushable = sortByUrgency(scored.filter(s => s.urgency !== 'ignore'));
|
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
* Best-effort CLI activity counter — tracks fetch, triage, and invocation
|
|
3
3
|
* counts between pushes. Stored in ~/.ticketlens/activity.json.
|
|
4
4
|
*
|
|
5
|
+
* fetch_count/triage_run_count are tracked per profile (data.byProfile), since
|
|
6
|
+
* they're always incremented at a point where a resolved profile is known and
|
|
7
|
+
* a push (`readAndResetActivity`) reports them for one specific profile — a
|
|
8
|
+
* push under profile A must never bundle in runs that actually happened under
|
|
9
|
+
* profile B (L-6, 2026-08-01 audit). Every other field (invocations, commands,
|
|
10
|
+
* drafts_kept, drafts_deleted, briefs_with_recall_injection) stays flat/global:
|
|
11
|
+
* they're incremented from bin/ticketlens.mjs before any command has resolved a
|
|
12
|
+
* profile at all (many commands, e.g. `license`/`cache`, have no profile concept
|
|
13
|
+
* whatsoever), so there's no meaningful profile to bucket them under.
|
|
14
|
+
*
|
|
5
15
|
* Intentional limitations (acceptable for a UX metric, NOT suitable for billing):
|
|
6
16
|
*
|
|
7
17
|
* - Not transactional: if a push succeeds server-side but the network returns
|
|
@@ -19,12 +29,13 @@ import fs from 'node:fs';
|
|
|
19
29
|
import path from 'node:path';
|
|
20
30
|
|
|
21
31
|
const ACTIVITY_FILE = 'activity.json';
|
|
32
|
+
const EMPTY_PROFILE_BUCKET = { fetch_count: 0, triage_run_count: 0 };
|
|
22
33
|
|
|
23
34
|
function read(configDir) {
|
|
24
35
|
try {
|
|
25
36
|
return JSON.parse(fs.readFileSync(path.join(configDir, ACTIVITY_FILE), 'utf8'));
|
|
26
37
|
} catch {
|
|
27
|
-
return {
|
|
38
|
+
return { byProfile: {}, invocations: 0, commands: {}, drafts_kept: 0, drafts_deleted: 0, briefs_with_recall_injection: 0 };
|
|
28
39
|
}
|
|
29
40
|
}
|
|
30
41
|
|
|
@@ -48,12 +59,26 @@ function increment(configDir, field) {
|
|
|
48
59
|
return data[field];
|
|
49
60
|
}
|
|
50
61
|
|
|
51
|
-
|
|
52
|
-
|
|
62
|
+
/**
|
|
63
|
+
* @param {string} configDir
|
|
64
|
+
* @param {string} profile
|
|
65
|
+
* @param {'fetch_count'|'triage_run_count'} field
|
|
66
|
+
*/
|
|
67
|
+
function incrementProfileField(configDir, profile, field) {
|
|
68
|
+
const data = read(configDir);
|
|
69
|
+
if (!data.byProfile) data.byProfile = {};
|
|
70
|
+
if (!data.byProfile[profile]) data.byProfile[profile] = { ...EMPTY_PROFILE_BUCKET };
|
|
71
|
+
const bucket = data.byProfile[profile];
|
|
72
|
+
bucket[field] = (bucket[field] ?? 0) + 1;
|
|
73
|
+
write(configDir, data);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function incrementFetch(configDir, profile) {
|
|
77
|
+
incrementProfileField(configDir, profile, 'fetch_count');
|
|
53
78
|
}
|
|
54
79
|
|
|
55
|
-
export function incrementTriageRun(configDir) {
|
|
56
|
-
|
|
80
|
+
export function incrementTriageRun(configDir, profile) {
|
|
81
|
+
incrementProfileField(configDir, profile, 'triage_run_count');
|
|
57
82
|
}
|
|
58
83
|
|
|
59
84
|
export function incrementInvocation(configDir) {
|
|
@@ -147,25 +172,35 @@ export function recordTokensSaved(configDir, command, tokens) {
|
|
|
147
172
|
}
|
|
148
173
|
|
|
149
174
|
/**
|
|
150
|
-
* Returns the current counters and resets them to zero.
|
|
151
|
-
*
|
|
175
|
+
* Returns the current counters and resets them to zero. Call only after a
|
|
176
|
+
* confirmed successful push, for the exact profile that push was for.
|
|
177
|
+
*
|
|
178
|
+
* fetch_count/triage_run_count are scoped to the given profile — only that
|
|
179
|
+
* profile's bucket is read and reset, so a push for profile A never bundles
|
|
180
|
+
* in (or clears) runs that happened under a different profile B. Every other
|
|
181
|
+
* field stays global, same as before: reset regardless of which profile
|
|
182
|
+
* triggered this push (see the module doc comment for why).
|
|
152
183
|
*
|
|
153
184
|
* @param {string} configDir
|
|
185
|
+
* @param {string} profile
|
|
154
186
|
* @returns {{ fetch_count: number, triage_run_count: number, invocations: number, commands: object }}
|
|
155
187
|
*/
|
|
156
|
-
export function readAndResetActivity(configDir) {
|
|
188
|
+
export function readAndResetActivity(configDir, profile) {
|
|
157
189
|
const data = read(configDir);
|
|
190
|
+
const profileBucket = data.byProfile?.[profile] ?? EMPTY_PROFILE_BUCKET;
|
|
158
191
|
const snapshot = {
|
|
159
|
-
fetch_count:
|
|
160
|
-
triage_run_count:
|
|
192
|
+
fetch_count: profileBucket.fetch_count ?? 0,
|
|
193
|
+
triage_run_count: profileBucket.triage_run_count ?? 0,
|
|
161
194
|
invocations: data.invocations ?? 0,
|
|
162
195
|
commands: data.commands ?? {},
|
|
163
196
|
drafts_kept: data.drafts_kept ?? 0,
|
|
164
197
|
drafts_deleted: data.drafts_deleted ?? 0,
|
|
165
198
|
briefs_with_recall_injection: data.briefs_with_recall_injection ?? 0,
|
|
166
199
|
};
|
|
200
|
+
const byProfile = { ...(data.byProfile ?? {}) };
|
|
201
|
+
delete byProfile[profile];
|
|
167
202
|
write(configDir, {
|
|
168
|
-
|
|
203
|
+
byProfile, invocations: 0, commands: {},
|
|
169
204
|
drafts_kept: 0, drafts_deleted: 0, briefs_with_recall_injection: 0,
|
|
170
205
|
...(data.pulses ? { pulses: data.pulses } : {}),
|
|
171
206
|
});
|
|
@@ -54,13 +54,13 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
54
54
|
` ${s.brand('ticketlens')} recall sync Retry any notes stuck in the local queue ${s.dim('[Team+]')}`,
|
|
55
55
|
` ${s.brand('ticketlens')} recall settings Show effective retry-queue settings, fetched live ${s.dim('[Team+]')}`,
|
|
56
56
|
` ${s.brand('ticketlens')} mcp Start the MCP stdio server (Recall + ticket writes) ${s.dim('[Pro]')}`,
|
|
57
|
-
` ${s.brand('ticketlens')} comment ${s.dim('<TICKET-KEY> --body=...')} Post a comment to the tracker ${s.dim('[Pro]')}`,
|
|
57
|
+
` ${s.brand('ticketlens')} comment ${s.dim('<TICKET-KEY> --body=... [--attach=...]')} Post a comment to the tracker ${s.dim('[Pro]')}`,
|
|
58
58
|
` ${s.brand('ticketlens')} transition ${s.dim('<TICKET-KEY> [--target=... --confirm]')} Move ticket status ${s.dim('[Pro]')}`,
|
|
59
59
|
` ${s.brand('ticketlens')} assign ${s.dim('<TICKET-KEY> --to=me')} Assign a ticket to yourself ${s.dim('[Pro]')}`,
|
|
60
60
|
` ${s.brand('ticketlens')} duplicates ${s.dim('<TICKET-KEY> [--threshold=N]')} Find likely duplicate tickets ${s.dim('[Pro]')}`,
|
|
61
61
|
` ${s.brand('ticketlens')} link ${s.dim('<SOURCE> <TARGET> [--type=... --confirm]')} Link two tickets ${s.dim('[Pro]')}`,
|
|
62
62
|
` ${s.brand('ticketlens')} update ${s.dim('<TICKET-KEY> [--title=... --description=... --priority=... --add-labels=... --remove-labels=...]')} Update fields ${s.dim('[Pro]')}`,
|
|
63
|
-
` ${s.brand('ticketlens')} create ${s.dim('--project=... [--type=...] --summary=... [--description=...]')} Create a new ticket ${s.dim('[Pro]')}`,
|
|
63
|
+
` ${s.brand('ticketlens')} create ${s.dim('--project=... [--type=...] --summary=... [--description=...] [--attach=...]')} Create a new ticket ${s.dim('[Pro]')}`,
|
|
64
64
|
'',
|
|
65
65
|
` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
|
|
66
66
|
` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
|
|
@@ -567,13 +567,20 @@ export function printNoteHelp({ stream = process.stdout } = {}) {
|
|
|
567
567
|
'',
|
|
568
568
|
` ${s.dim('$')} echo "Retry needs exponential backoff" | ticketlens note add --title="Retry gotcha" --ticket=PROD-123 --tags=bug`,
|
|
569
569
|
'',
|
|
570
|
-
` ${s.bold('ticketlens note patch')} ${s.dim('--id="..." [--ticket=KEY]')} ${s.dim('[Pro]')}`,
|
|
570
|
+
` ${s.bold('ticketlens note patch')} ${s.dim('--id="..." [--ticket=KEY] [--expect-mtime=MS]')} ${s.dim('[Pro]')}`,
|
|
571
571
|
'',
|
|
572
572
|
` Overwrites an existing note's body with a better draft, read from stdin.`,
|
|
573
573
|
` Internal mechanism used by the jtb skill's note quality loop inside a Claude`,
|
|
574
574
|
` Code session — not typically invoked by hand. Every note it writes gets the`,
|
|
575
575
|
` same structural and secret-scan checks ${s.brand('note add')} applies to user input.`,
|
|
576
576
|
'',
|
|
577
|
+
` ${s.bold('OPTIONS')}`,
|
|
578
|
+
'',
|
|
579
|
+
` ${s.brand('--id')}=${s.dim('ID')} Note id to patch ${s.dim('(required)')}`,
|
|
580
|
+
` ${s.brand('--ticket')}=${s.dim('KEY')} Ticket this note is about ${s.dim('(optional)')}`,
|
|
581
|
+
` ${s.brand('--expect-mtime')}=${s.dim('MS')} File mtime (ms) the caller last observed ${s.dim('(optional)')} — a no-op,`,
|
|
582
|
+
` not an overwrite, if the file changed since then`,
|
|
583
|
+
'',
|
|
577
584
|
` ${s.bold('ticketlens note delete')} ${s.dim('--id="..." [--ticket=KEY] [--yes]')} ${s.dim('[Pro]')}`,
|
|
578
585
|
'',
|
|
579
586
|
` Removes a note from your local vault. Local only — if this note was already`,
|
|
@@ -646,6 +653,10 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
646
653
|
` ${s.dim('restart or reconnect the MCP client to pick up the new schema. The running')}`,
|
|
647
654
|
` ${s.dim('server is always current; only the client-side copy goes stale.')}`,
|
|
648
655
|
'',
|
|
656
|
+
` ${s.dim('The [Pro] badge above describes the tools this server exposes — starting the')}`,
|
|
657
|
+
` ${s.dim('server itself is ungated, same as `mcp install`; each tool still enforces its')}`,
|
|
658
|
+
` ${s.dim('own license check at call time.')}`,
|
|
659
|
+
'',
|
|
649
660
|
` ${s.bold('OPTIONS')}`,
|
|
650
661
|
'',
|
|
651
662
|
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
@@ -976,6 +987,7 @@ export function printTriageHelp({ stream = process.stdout } = {}) {
|
|
|
976
987
|
` ${s.brand('--digest')} POST scored results to digest endpoint ${s.dim('[Pro]')}`,
|
|
977
988
|
` ${s.brand('--static')} Static table output ${s.dim('(skip interactive mode)')}`,
|
|
978
989
|
` ${s.brand('--plain')} Plain markdown output`,
|
|
990
|
+
` ${s.brand('--styled')} Force ANSI-styled output ${s.dim('(even when piped)')}`,
|
|
979
991
|
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
980
992
|
'',
|
|
981
993
|
` ${s.bold('EXAMPLES')}`,
|
|
@@ -36,7 +36,7 @@ const TOOLS = [
|
|
|
36
36
|
properties: {
|
|
37
37
|
title: { type: 'string', description: 'Short one-line title.' },
|
|
38
38
|
ticket: { type: 'string', description: 'Optional ticket key, e.g. PROJ-123.' },
|
|
39
|
-
tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags derived from this note\'s actual content — the specific technology, error type, root cause, or affected component (e.g. "retry-backoff", "null-pointer", "auth-middleware"). Never the project name or a generic category word like "gotcha" or "bug" — those provide no search signal to someone else looking for this note later.' },
|
|
39
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags derived from this note\'s actual content — the specific technology, error type, root cause, or affected component (e.g. "retry-backoff", "null-pointer", "auth-middleware"). Never the project name or a generic category word like "gotcha" or "bug" — those provide no search signal to someone else looking for this note later. A tag that just restates the title in different words, or one you cannot trace to a specific sentence in the body, gives that same zero signal — if you cannot point to the exact phrase that justifies it, drop it.' },
|
|
40
40
|
body: { type: 'string', description: 'The note body — one or more paragraphs.' },
|
|
41
41
|
},
|
|
42
42
|
required: ['title', 'body'],
|
|
@@ -119,7 +119,7 @@ const TOOLS = [
|
|
|
119
119
|
},
|
|
120
120
|
{
|
|
121
121
|
name: 'ticket_update',
|
|
122
|
-
description: 'Update a narrow, named field set on a ticket in its tracker (Jira/GitHub/Linear) — title, description, labels, priority. Labels are add/remove, never a wholesale replace: an unnamed existing label is left alone. No discovery step and no confirm required — these are reversible metadata edits, not workflow-state changes. GitHub has no priority field; passing `priority` for a GitHub-tracked ticket is refused. A call can partially succeed (e.g. title updates but a label does not resolve) — the result reports exactly what landed. Requires a TicketLens Pro license.',
|
|
122
|
+
description: 'Update a narrow, named field set on a ticket in its tracker (Jira/GitHub/Linear) — title, description, labels, priority. At least one field is required. Labels are add/remove, never a wholesale replace: an unnamed existing label is left alone. No discovery step and no confirm required — these are reversible metadata edits, not workflow-state changes. GitHub has no priority field; passing `priority` for a GitHub-tracked ticket is refused. A call can partially succeed (e.g. title updates but a label does not resolve) — the result reports exactly what landed. Requires a TicketLens Pro license.',
|
|
123
123
|
inputSchema: {
|
|
124
124
|
type: 'object',
|
|
125
125
|
properties: {
|
|
@@ -245,13 +245,13 @@ async function callTicketTransition(args, { configDir, runTicketTransitionListFn
|
|
|
245
245
|
}
|
|
246
246
|
const capture = capturingStream();
|
|
247
247
|
if (!args.target) {
|
|
248
|
-
const { ok } = await runTicketTransitionListFn([args.ticket], { configDir, stream: capture });
|
|
248
|
+
const { ok } = await runTicketTransitionListFn([args.ticket], { configDir, stream: capture, cliHints: false });
|
|
249
249
|
const content = [{ type: 'text', text: capture.text }];
|
|
250
250
|
return ok ? { content } : { isError: true, content };
|
|
251
251
|
}
|
|
252
252
|
const cmdArgs = [args.ticket, `--target=${args.target}`];
|
|
253
253
|
if (args.confirm === true) cmdArgs.push('--confirm');
|
|
254
|
-
const { ok } = await runTicketTransitionFn(cmdArgs, { configDir, stream: capture });
|
|
254
|
+
const { ok } = await runTicketTransitionFn(cmdArgs, { configDir, stream: capture, cliHints: false });
|
|
255
255
|
const content = [{ type: 'text', text: capture.text }];
|
|
256
256
|
return ok ? { content } : { isError: true, content };
|
|
257
257
|
}
|
|
@@ -296,13 +296,13 @@ async function callTicketLink(args, { configDir, runTicketLinkListFn, runTicketL
|
|
|
296
296
|
}
|
|
297
297
|
const capture = capturingStream();
|
|
298
298
|
if (!args.type) {
|
|
299
|
-
const { ok } = await runTicketLinkListFn([args.ticket, args.target], { configDir, stream: capture });
|
|
299
|
+
const { ok } = await runTicketLinkListFn([args.ticket, args.target], { configDir, stream: capture, cliHints: false });
|
|
300
300
|
const content = [{ type: 'text', text: capture.text }];
|
|
301
301
|
return ok ? { content } : { isError: true, content };
|
|
302
302
|
}
|
|
303
303
|
const cmdArgs = [args.ticket, args.target, `--type=${args.type}`];
|
|
304
304
|
if (args.confirm === true) cmdArgs.push('--confirm');
|
|
305
|
-
const { ok } = await runTicketLinkFn(cmdArgs, { configDir, stream: capture });
|
|
305
|
+
const { ok } = await runTicketLinkFn(cmdArgs, { configDir, stream: capture, cliHints: false });
|
|
306
306
|
const content = [{ type: 'text', text: capture.text }];
|
|
307
307
|
return ok ? { content } : { isError: true, content };
|
|
308
308
|
}
|
|
@@ -11,7 +11,7 @@ import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
|
11
11
|
import { isLicensed, showUpgradePrompt } from './license.mjs';
|
|
12
12
|
import { scanForSecrets } from './secret-scanner.mjs';
|
|
13
13
|
import { checkNoteStructure } from './note-structural-check.mjs';
|
|
14
|
-
import { writeNote, patchNoteBody, deleteNote, rebuildIndex } from './recall-vault.mjs';
|
|
14
|
+
import { writeNote, patchNoteBody, deleteNote, deleteNoteAnyPrefix, rebuildIndex } from './recall-vault.mjs';
|
|
15
15
|
import { readCliToken } from './cli-auth.mjs';
|
|
16
16
|
import { pushNote } from './recall-sync.mjs';
|
|
17
17
|
import { enqueueNote, isRetryableFailure, maybeAutoFlush } from './recall-queue.mjs';
|
|
@@ -230,6 +230,7 @@ export async function runNoteDelete(cmdArgs, {
|
|
|
230
230
|
stdin = process.stdin,
|
|
231
231
|
isLicensedFn = isLicensed,
|
|
232
232
|
deleteNoteFn = deleteNote,
|
|
233
|
+
deleteNoteAnyPrefixFn = deleteNoteAnyPrefix,
|
|
233
234
|
rebuildIndexFn = rebuildIndex,
|
|
234
235
|
confirmFn = confirmDestructive,
|
|
235
236
|
} = {}) {
|
|
@@ -257,7 +258,9 @@ export async function runNoteDelete(cmdArgs, {
|
|
|
257
258
|
return { deleted: false };
|
|
258
259
|
}
|
|
259
260
|
|
|
260
|
-
const { deleted, prefix } =
|
|
261
|
+
const { deleted, prefix } = ticketKey
|
|
262
|
+
? deleteNoteFn({ external_id: id, tickets: [ticketKey] }, { configDir })
|
|
263
|
+
: deleteNoteAnyPrefixFn(id, { configDir });
|
|
261
264
|
if (deleted) rebuildIndexFn(prefix, { configDir });
|
|
262
265
|
stream.write(deleted
|
|
263
266
|
? ` Deleted note (${id}) — local vault only; see help for team-synced notes.\n`
|
|
@@ -17,7 +17,7 @@ import { listNotes } from './recall-vault.mjs';
|
|
|
17
17
|
import { readCliToken } from './cli-auth.mjs';
|
|
18
18
|
import { pullNotes } from './recall-sync.mjs';
|
|
19
19
|
import { maybeAutoFlush, flushQueue, readQueue } from './recall-queue.mjs';
|
|
20
|
-
import {
|
|
20
|
+
import { getEffectiveRecallSettingsWithSource } from './recall-settings-sync.mjs';
|
|
21
21
|
import { styleRecallResults } from './styled-assembler.mjs';
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -98,6 +98,13 @@ export async function runRecallSync(cmdArgs, {
|
|
|
98
98
|
return { ok: true };
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// Keyed by the `source` getEffectiveRecallSettingsWithSource reports.
|
|
102
|
+
const SOURCE_LABELS = {
|
|
103
|
+
'no-token': 'platform default — log in to pick up a team override',
|
|
104
|
+
'live': 'your team manager (or platform default if unset), fetched live just now',
|
|
105
|
+
'cache-fallback': 'last known-good cached value — live fetch failed, could be stale',
|
|
106
|
+
};
|
|
107
|
+
|
|
101
108
|
/**
|
|
102
109
|
* Implements `tl recall settings` — read-only display of the effective
|
|
103
110
|
* Recall queue settings (flush cooldown, per-request timeout, max queue
|
|
@@ -114,7 +121,7 @@ export async function runRecallSettings(cmdArgs, {
|
|
|
114
121
|
stream = process.stdout,
|
|
115
122
|
isLicensedFn = isLicensed,
|
|
116
123
|
readCliTokenFn = readCliToken,
|
|
117
|
-
|
|
124
|
+
getEffectiveRecallSettingsWithSourceFn = getEffectiveRecallSettingsWithSource,
|
|
118
125
|
} = {}) {
|
|
119
126
|
if (!isLicensedFn('pro', configDir)) {
|
|
120
127
|
showUpgradePrompt('pro', 'ticketlens recall', { stream });
|
|
@@ -122,13 +129,12 @@ export async function runRecallSettings(cmdArgs, {
|
|
|
122
129
|
}
|
|
123
130
|
|
|
124
131
|
const cliToken = readCliTokenFn(configDir);
|
|
125
|
-
const settings = await
|
|
126
|
-
const source = cliToken ? 'your team manager (or platform default if unset)' : 'platform default — log in to pick up a team override';
|
|
132
|
+
const { values: settings, source } = await getEffectiveRecallSettingsWithSourceFn({ cliToken, configDir });
|
|
127
133
|
|
|
128
134
|
stream.write(` Retry cooldown: ${settings.flush_cooldown_ms / 60_000} min\n`);
|
|
129
135
|
stream.write(` Per-request timeout: ${settings.timeout_ms / 1_000} sec\n`);
|
|
130
136
|
stream.write(` Max queued notes: ${settings.max_queue_size}\n`);
|
|
131
137
|
stream.write(` Queued note expiry: ${settings.max_entry_age_ms / 86_400_000} days\n`);
|
|
132
|
-
stream.write(` Source: ${source}\n`);
|
|
138
|
+
stream.write(` Source: ${SOURCE_LABELS[source]}\n`);
|
|
133
139
|
return { ok: true };
|
|
134
140
|
}
|
|
@@ -136,23 +136,29 @@ export async function fetchRecallSettings({
|
|
|
136
136
|
* with a hash of cliToken so a later account switch never serves a stale
|
|
137
137
|
* team's settings as a "fallback."
|
|
138
138
|
*
|
|
139
|
+
* Also reports how the values were obtained, which `recall settings` needs
|
|
140
|
+
* for an accurate "Source:" line — cliToken presence alone can't tell those
|
|
141
|
+
* apart, since a live fetch and a silent cache fallback both had a token but
|
|
142
|
+
* only one is actually current. Callers that don't care use the values-only
|
|
143
|
+
* getEffectiveRecallSettings below.
|
|
144
|
+
*
|
|
139
145
|
* @param {object} opts
|
|
140
146
|
* @param {string} [opts.cliToken]
|
|
141
147
|
* @param {string} [opts.configDir]
|
|
142
148
|
* @param {() => number} [opts.now]
|
|
143
149
|
* @param {Function} [opts.fetchRecallSettingsFn]
|
|
144
|
-
* @returns {Promise<{
|
|
150
|
+
* @returns {Promise<{values: object, source: 'no-token'|'live'|'cache-fallback'}>}
|
|
145
151
|
*/
|
|
146
|
-
export async function
|
|
152
|
+
export async function getEffectiveRecallSettingsWithSource({
|
|
147
153
|
cliToken,
|
|
148
154
|
configDir = DEFAULT_CONFIG_DIR,
|
|
149
155
|
now = () => Date.now(),
|
|
150
156
|
fetchRecallSettingsFn = fetchRecallSettings,
|
|
151
157
|
} = {}) {
|
|
152
|
-
if (!cliToken) return readEffectiveRecallSettings(configDir);
|
|
158
|
+
if (!cliToken) return { values: readEffectiveRecallSettings(configDir), source: 'no-token' };
|
|
153
159
|
|
|
154
160
|
const result = await fetchRecallSettingsFn({ cliToken });
|
|
155
|
-
if (!result.ok) return readEffectiveRecallSettings(configDir, { cliToken });
|
|
161
|
+
if (!result.ok) return { values: readEffectiveRecallSettings(configDir, { cliToken }), source: 'cache-fallback' };
|
|
156
162
|
|
|
157
163
|
writeFileAtomically(cachePath(configDir), JSON.stringify({
|
|
158
164
|
values: result.values,
|
|
@@ -164,5 +170,17 @@ export async function getEffectiveRecallSettings({
|
|
|
164
170
|
for (const field of Object.keys(DEFAULT_RECALL_SETTINGS)) {
|
|
165
171
|
effective[field] = clamp(field, result.values[field]);
|
|
166
172
|
}
|
|
167
|
-
return effective;
|
|
173
|
+
return { values: effective, source: 'live' };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Values-only view of the same resolution — the contract every caller other
|
|
178
|
+
* than `recall settings` (recall-queue.mjs) uses.
|
|
179
|
+
*
|
|
180
|
+
* @param {object} opts - same as getEffectiveRecallSettingsWithSource
|
|
181
|
+
* @returns {Promise<{flush_cooldown_ms: number, timeout_ms: number, max_queue_size: number, max_entry_age_ms: number}>}
|
|
182
|
+
*/
|
|
183
|
+
export async function getEffectiveRecallSettings(opts = {}) {
|
|
184
|
+
const { values } = await getEffectiveRecallSettingsWithSource(opts);
|
|
185
|
+
return values;
|
|
168
186
|
}
|
|
@@ -164,6 +164,36 @@ export function deleteNote({ external_id: externalId, tickets = [] }, { configDi
|
|
|
164
164
|
return { deleted: true, prefix };
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Deletes a note when the caller doesn't know (or a CLI user didn't specify)
|
|
169
|
+
* which ticket-prefix folder it lives in — used only by `note delete` when
|
|
170
|
+
* --ticket is omitted. Unlike deleteNote, which resolves a single
|
|
171
|
+
* deterministic path from tickets[0] (required for tombstone processing to
|
|
172
|
+
* stay exact — a tombstone's tickets:[] genuinely means "this note has no
|
|
173
|
+
* ticket," not "unknown"), this searches every existing prefix folder, the
|
|
174
|
+
* same "no prefix given" fallback listNotes already uses below. _general is
|
|
175
|
+
* tried first since that's where a truly general note lives.
|
|
176
|
+
*
|
|
177
|
+
* @param {string} externalId
|
|
178
|
+
* @param {{ configDir?: string }} [opts]
|
|
179
|
+
* @returns {{ deleted: boolean, prefix: string|null }}
|
|
180
|
+
*/
|
|
181
|
+
export function deleteNoteAnyPrefix(externalId, { configDir = DEFAULT_CONFIG_DIR } = {}) {
|
|
182
|
+
if (!EXTERNAL_ID_PATTERN.test(externalId)) {
|
|
183
|
+
throw new Error(`Invalid externalId: "${externalId}"`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const prefixes = [GENERAL_BUCKET, ...allPrefixDirs(configDir).filter(p => p !== GENERAL_BUCKET)];
|
|
187
|
+
for (const prefix of prefixes) {
|
|
188
|
+
const notePath = path.join(prefixDir(configDir, prefix), externalId);
|
|
189
|
+
if (fs.existsSync(notePath)) {
|
|
190
|
+
fs.unlinkSync(notePath);
|
|
191
|
+
return { deleted: true, prefix };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { deleted: false, prefix: null };
|
|
195
|
+
}
|
|
196
|
+
|
|
167
197
|
/**
|
|
168
198
|
* Overwrites an existing local note's body in place — used by the jtb skill's
|
|
169
199
|
* generator/validator quality loop to swap in a better draft after `note add`
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { computeResponseMetrics, DEFAULT_CONFIG_DIR } from './triage-history.mjs';
|
|
2
|
-
import {
|
|
2
|
+
import { resolveConnection } from './profile-resolver.mjs';
|
|
3
3
|
import { isLicensed as defaultIsLicensed } from './license.mjs';
|
|
4
4
|
import { createStyler } from './ansi.mjs';
|
|
5
5
|
import { handleUnknownFlags } from './arg-validator.mjs';
|
|
@@ -51,12 +51,11 @@ export async function runStats(args = [], opts = {}) {
|
|
|
51
51
|
const isPro = isLic('pro', configDir);
|
|
52
52
|
const days = (!isPro && parsedDays > FREE_DAYS_CAP) ? FREE_DAYS_CAP : parsedDays;
|
|
53
53
|
|
|
54
|
-
// Resolve profile name
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
54
|
+
// Resolve profile name (same chain as triage: --profile flag → cwd projectPaths → config.default)
|
|
55
|
+
const explicitProfile = profileArg ? profileArg.split('=')[1] : undefined;
|
|
56
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
57
|
+
const conn = resolveConnection(null, { configDir, profileName: explicitProfile, cwd });
|
|
58
|
+
const profile = conn.profileName ?? 'default';
|
|
60
59
|
|
|
61
60
|
let metrics;
|
|
62
61
|
try {
|
|
@@ -142,19 +142,31 @@ function isLabelWord(token) {
|
|
|
142
142
|
* Rejoins runs of adjacent whitespace-separated tokens (no separator) so a secret
|
|
143
143
|
* broken up by whitespace — accidental soft-wrap, or a deliberate space/tab/
|
|
144
144
|
* newline inserted to dodge the scanner — still reads as one contiguous string.
|
|
145
|
-
*
|
|
146
|
-
*
|
|
145
|
+
*
|
|
146
|
+
* `stopAtLabelWords` (the default) ends a run at a label word (see isLabelWord),
|
|
147
|
+
* so a real context word like "commit" can't glue onto an unrelated payload and
|
|
148
|
+
* trip the entropy heuristic. Pass false for the HARD_REJECT_PATTERNS pass: those
|
|
149
|
+
* match an exact literal prefix (AKIA, sk-, ghp_, eyJ, -----BEGIN) rather than
|
|
150
|
+
* guessing from shape, so ordinary prose can't turn into a false positive there —
|
|
151
|
+
* and a fixed secret prefix can itself look like an ordinary word to isLabelWord
|
|
152
|
+
* ("AKIA" is indistinguishable from an acronym by that heuristic), which would
|
|
153
|
+
* otherwise stop it from ever rejoining with a whitespace-split suffix.
|
|
154
|
+
*
|
|
155
|
+
* Either way a run only ever extends forward from its own start index, so a word
|
|
156
|
+
* standing immediately *before* a secret is never glued onto it — that's what
|
|
157
|
+
* keeps the leading \b anchor in the boundary-anchored patterns intact.
|
|
147
158
|
*
|
|
148
159
|
* @param {string[]} tokens
|
|
160
|
+
* @param {{ stopAtLabelWords?: boolean }} [opts]
|
|
149
161
|
* @returns {string[]}
|
|
150
162
|
*/
|
|
151
|
-
function joinedChunkRuns(tokens) {
|
|
163
|
+
function joinedChunkRuns(tokens, { stopAtLabelWords = true } = {}) {
|
|
152
164
|
const runs = [];
|
|
153
165
|
for (let i = 0; i < tokens.length; i++) {
|
|
154
|
-
if (isLabelWord(tokens[i])) continue;
|
|
166
|
+
if (stopAtLabelWords && isLabelWord(tokens[i])) continue;
|
|
155
167
|
let joined = tokens[i];
|
|
156
168
|
for (let j = i + 1; j < Math.min(tokens.length, i + MAX_JOINED_CHUNKS); j++) {
|
|
157
|
-
if (isLabelWord(tokens[j])) break;
|
|
169
|
+
if (stopAtLabelWords && isLabelWord(tokens[j])) break;
|
|
158
170
|
joined += tokens[j];
|
|
159
171
|
runs.push(joined);
|
|
160
172
|
}
|
|
@@ -171,19 +183,34 @@ export function scanForSecrets({ title = '', tags = [], body = '' } = {}) {
|
|
|
171
183
|
const reasons = [];
|
|
172
184
|
const warnings = [];
|
|
173
185
|
|
|
186
|
+
const tokens = combined.split(/\s+/).filter(Boolean);
|
|
187
|
+
const candidates = [...tokens, ...joinedChunkRuns(tokens)];
|
|
188
|
+
|
|
174
189
|
// A known secret shape (AWS key, API key prefix, JWT, PEM block...) is recognized
|
|
175
|
-
// by a specific literal prefix
|
|
176
|
-
//
|
|
177
|
-
//
|
|
190
|
+
// by a specific literal prefix. Checked three ways, each catching what the
|
|
191
|
+
// others miss:
|
|
192
|
+
// 1. combined — the unsplit occurrence.
|
|
193
|
+
// 2. hardRejectRuns — each individually rejoined run (bounded to
|
|
194
|
+
// MAX_JOINED_CHUNKS tokens), which keeps the leading \b anchor intact
|
|
195
|
+
// for the two boundary-anchored patterns (API key, GitHub token) —
|
|
196
|
+
// stripping whitespace from the whole note at once would instead glue
|
|
197
|
+
// an unrelated preceding word onto the secret's first character and
|
|
198
|
+
// kill that anchor. This is the primary, anchor-safe check.
|
|
199
|
+
// 3. despacedCombined — the whole note with all whitespace stripped,
|
|
200
|
+
// unbounded in length. A fallback for fragmentation wider than
|
|
201
|
+
// MAX_JOINED_CHUNKS tokens (e.g. a secret typed one character per
|
|
202
|
+
// token) that #2's bounded window can't reach. Reintroduces the same
|
|
203
|
+
// anchor risk #2 was built to avoid, but only as an additional check
|
|
204
|
+
// alongside #2, never instead of it — it can only add a detection
|
|
205
|
+
// that #1/#2 missed, never remove one they already caught.
|
|
178
206
|
const despacedCombined = combined.replace(/\s+/g, '');
|
|
207
|
+
const hardRejectRuns = joinedChunkRuns(tokens, { stopAtLabelWords: false });
|
|
179
208
|
for (const { name, re } of HARD_REJECT_PATTERNS) {
|
|
180
|
-
if (re.test(combined) || re.test(despacedCombined)) {
|
|
209
|
+
if (re.test(combined) || hardRejectRuns.some(c => re.test(c)) || re.test(despacedCombined)) {
|
|
181
210
|
reasons.push(`Looks like a${/^[aeiou]/i.test(name) ? 'n' : ''} ${name}.`);
|
|
182
211
|
}
|
|
183
212
|
}
|
|
184
213
|
|
|
185
|
-
const tokens = combined.split(/\s+/).filter(Boolean);
|
|
186
|
-
const candidates = [...tokens, ...joinedChunkRuns(tokens)];
|
|
187
214
|
// A candidate containing a real email address is skipped: with ordinary words
|
|
188
215
|
// now eligible to join (needed to catch e.g. "wall"-separated or base64-letter-
|
|
189
216
|
// only split secrets), an email joined with an adjacent word can produce a long
|
|
@@ -311,6 +311,9 @@ export async function runTicketComment(cmdArgs, {
|
|
|
311
311
|
* transition options for the ticket.
|
|
312
312
|
*
|
|
313
313
|
* @param {string[]} cmdArgs - [ticketKey]
|
|
314
|
+
* @param {boolean} [cliHints] - true (default) prints CLI flag syntax
|
|
315
|
+
* (--target=, --confirm) in the hint; false prints MCP-shaped named-arg
|
|
316
|
+
* wording instead — set by mcp-server.mjs's ticket_transition call sites.
|
|
314
317
|
* @returns {Promise<{ ok: boolean, options?: object[] }>}
|
|
315
318
|
*/
|
|
316
319
|
export async function runTicketTransitionList(cmdArgs, {
|
|
@@ -319,6 +322,7 @@ export async function runTicketTransitionList(cmdArgs, {
|
|
|
319
322
|
isLicensedFn = isLicensed,
|
|
320
323
|
resolveConnectionFn = resolveConnection,
|
|
321
324
|
resolveAdapterFn = resolveAdapter,
|
|
325
|
+
cliHints = true,
|
|
322
326
|
} = {}) {
|
|
323
327
|
const usage = 'Usage: ticketlens transition TICKET-KEY [--target="..." --confirm]\n';
|
|
324
328
|
if (!requireLicense(isLicensedFn, configDir, 'ticketlens transition', stream)) return { ok: false };
|
|
@@ -338,7 +342,9 @@ export async function runTicketTransitionList(cmdArgs, {
|
|
|
338
342
|
}
|
|
339
343
|
stream.write(` Valid transitions for ${s.brand(s.bold(ticketKey))}:\n\n`);
|
|
340
344
|
for (const o of options) stream.write(` ${s.brand('●')} ${o.name}\n`);
|
|
341
|
-
stream.write(
|
|
345
|
+
stream.write(cliHints
|
|
346
|
+
? `\n Run again with --target="<name>" --confirm to execute.\n`
|
|
347
|
+
: `\n Call again with target="<name>" and confirm: true to execute.\n`);
|
|
342
348
|
return { ok: true, options };
|
|
343
349
|
} catch (err) {
|
|
344
350
|
stream.write(formatWriteFailure(ticketKey, err));
|
|
@@ -351,6 +357,7 @@ export async function runTicketTransitionList(cmdArgs, {
|
|
|
351
357
|
* without --confirm is treated as incomplete input, never silently executed.
|
|
352
358
|
*
|
|
353
359
|
* @param {string[]} cmdArgs - [ticketKey, '--target=...', '--confirm']
|
|
360
|
+
* @param {boolean} [cliHints] - see runTicketTransitionList's cliHints doc
|
|
354
361
|
* @returns {Promise<{ ok: boolean, reason?: string }>}
|
|
355
362
|
*/
|
|
356
363
|
export async function runTicketTransition(cmdArgs, {
|
|
@@ -363,6 +370,7 @@ export async function runTicketTransition(cmdArgs, {
|
|
|
363
370
|
recordActionFn = recordAction,
|
|
364
371
|
logActionFn = logAction,
|
|
365
372
|
actor = os.userInfo().username,
|
|
373
|
+
cliHints = true,
|
|
366
374
|
} = {}) {
|
|
367
375
|
const usage = 'Usage: ticketlens transition TICKET-KEY --target="..." --confirm\n';
|
|
368
376
|
if (!requireLicense(isLicensedFn, configDir, 'ticketlens transition', stream)) return { ok: false };
|
|
@@ -376,7 +384,9 @@ export async function runTicketTransition(cmdArgs, {
|
|
|
376
384
|
return { ok: false };
|
|
377
385
|
}
|
|
378
386
|
if (!cmdArgs.includes('--confirm')) {
|
|
379
|
-
stream.write(
|
|
387
|
+
stream.write(cliHints
|
|
388
|
+
? ` Refusing to transition ${ticketKey} to "${target}" without --confirm. Re-run with --confirm once you've reviewed the target.\n`
|
|
389
|
+
: ` Refusing to transition ${ticketKey} to "${target}" without confirm: true. Call again with confirm: true once you've reviewed the target.\n`);
|
|
380
390
|
return { ok: false };
|
|
381
391
|
}
|
|
382
392
|
|
|
@@ -552,6 +562,7 @@ export async function runTicketDuplicates(cmdArgs, {
|
|
|
552
562
|
* so that asymmetry is surfaced here before a caller ever reaches --confirm.
|
|
553
563
|
*
|
|
554
564
|
* @param {string[]} cmdArgs - [sourceKey, targetKey]
|
|
565
|
+
* @param {boolean} [cliHints] - see runTicketTransitionList's cliHints doc
|
|
555
566
|
* @returns {Promise<{ ok: boolean, types?: string[] }>}
|
|
556
567
|
*/
|
|
557
568
|
export async function runTicketLinkList(cmdArgs, {
|
|
@@ -560,6 +571,7 @@ export async function runTicketLinkList(cmdArgs, {
|
|
|
560
571
|
isLicensedFn = isLicensed,
|
|
561
572
|
resolveConnectionFn = resolveConnection,
|
|
562
573
|
resolveAdapterFn = resolveAdapter,
|
|
574
|
+
cliHints = true,
|
|
563
575
|
} = {}) {
|
|
564
576
|
const usage = 'Usage: ticketlens link SOURCE-KEY TARGET-KEY [--type="..." --confirm]\n';
|
|
565
577
|
if (!requireLicense(isLicensedFn, configDir, 'ticketlens link', stream)) return { ok: false };
|
|
@@ -585,7 +597,9 @@ export async function runTicketLinkList(cmdArgs, {
|
|
|
585
597
|
if (adapter.type === 'github') {
|
|
586
598
|
stream.write(` Note: GitHub has no generic link relationship — linking will CLOSE ${sourceKey} as a duplicate of ${targetKey}.\n`);
|
|
587
599
|
}
|
|
588
|
-
stream.write(
|
|
600
|
+
stream.write(cliHints
|
|
601
|
+
? ` Run again with --type="<name>" --confirm to execute — ${sourceKey} will be recorded as the one that "types" ${targetKey}.\n`
|
|
602
|
+
: ` Call again with type="<name>" and confirm: true to execute — ${sourceKey} will be recorded as the one that "types" ${targetKey}.\n`);
|
|
589
603
|
return { ok: true, types };
|
|
590
604
|
} catch (err) {
|
|
591
605
|
stream.write(formatLinkListFailure(sourceKey, err));
|
|
@@ -602,6 +616,7 @@ export async function runTicketLinkList(cmdArgs, {
|
|
|
602
616
|
* anything else) with targetKey/type carried in detail instead.
|
|
603
617
|
*
|
|
604
618
|
* @param {string[]} cmdArgs - [sourceKey, targetKey, '--type=...', '--confirm']
|
|
619
|
+
* @param {boolean} [cliHints] - see runTicketTransitionList's cliHints doc
|
|
605
620
|
* @returns {Promise<{ ok: boolean, reason?: string }>}
|
|
606
621
|
*/
|
|
607
622
|
export async function runTicketLink(cmdArgs, {
|
|
@@ -614,6 +629,7 @@ export async function runTicketLink(cmdArgs, {
|
|
|
614
629
|
recordActionFn = recordAction,
|
|
615
630
|
logActionFn = logAction,
|
|
616
631
|
actor = os.userInfo().username,
|
|
632
|
+
cliHints = true,
|
|
617
633
|
} = {}) {
|
|
618
634
|
const usage = 'Usage: ticketlens link SOURCE-KEY TARGET-KEY --type="..." --confirm\n';
|
|
619
635
|
if (!requireLicense(isLicensedFn, configDir, 'ticketlens link', stream)) return { ok: false };
|
|
@@ -629,7 +645,9 @@ export async function runTicketLink(cmdArgs, {
|
|
|
629
645
|
return { ok: false };
|
|
630
646
|
}
|
|
631
647
|
if (!cmdArgs.includes('--confirm')) {
|
|
632
|
-
stream.write(
|
|
648
|
+
stream.write(cliHints
|
|
649
|
+
? ` Refusing to link ${sourceKey} to ${targetKey} as "${type}" without --confirm. Re-run with --confirm once you've reviewed the target.\n`
|
|
650
|
+
: ` Refusing to link ${sourceKey} to ${targetKey} as "${type}" without confirm: true. Call again with confirm: true once you've reviewed the target.\n`);
|
|
633
651
|
return { ok: false };
|
|
634
652
|
}
|
|
635
653
|
|
|
@@ -70,7 +70,23 @@ export function saveTriageSnapshot(tickets, {
|
|
|
70
70
|
const dir = join(configDir, 'triage-history', dateStr);
|
|
71
71
|
fsModule.mkdirSync(dir, { recursive: true });
|
|
72
72
|
const filePath = snapshotPath(configDir, dateStr, profile);
|
|
73
|
-
|
|
73
|
+
|
|
74
|
+
// A same-day re-run (e.g. a narrower, filtered, or exploratory `triage`
|
|
75
|
+
// invocation after an earlier fuller one) must not silently discard
|
|
76
|
+
// tickets the earlier run already captured for today — stats/history read
|
|
77
|
+
// this file as "the" snapshot for the day. The current run's data always
|
|
78
|
+
// wins for any ticket it covers (freshest known state); a ticket only
|
|
79
|
+
// present in the earlier snapshot is carried forward untouched.
|
|
80
|
+
const earlier = loadSnapshotForDate(dateStr, profile, configDir, fsModule);
|
|
81
|
+
const earlierTickets = Array.isArray(earlier?.tickets) ? earlier.tickets : [];
|
|
82
|
+
const currentKeys = new Set(tickets.map(t => t.ticketKey));
|
|
83
|
+
const merged = [...tickets, ...earlierTickets.filter(t => !currentKeys.has(t.ticketKey))];
|
|
84
|
+
|
|
85
|
+
// `date` is the same local dateStr the file is stored under — captured_at
|
|
86
|
+
// stays UTC for precise ordering, but nothing should ever need to infer
|
|
87
|
+
// "which day" a snapshot belongs to by parsing captured_at, since that can
|
|
88
|
+
// disagree with the local-date directory name near midnight.
|
|
89
|
+
const envelope = { captured_at: now.toISOString(), date: dateStr, tickets: merged };
|
|
74
90
|
fsModule.writeFileSync(filePath, JSON.stringify(envelope, null, 2), 'utf8');
|
|
75
91
|
}
|
|
76
92
|
|
|
@@ -73,6 +73,9 @@ export async function pushTriageSnapshot({
|
|
|
73
73
|
tickets = tickets.map(t => {
|
|
74
74
|
const entry = latestByKey.get(t.key);
|
|
75
75
|
if (!entry) return t;
|
|
76
|
+
if (entry.noCriteria) {
|
|
77
|
+
return { ...t, compliance_coverage: null, compliance_status: 'no-criteria' };
|
|
78
|
+
}
|
|
76
79
|
return {
|
|
77
80
|
...t,
|
|
78
81
|
compliance_coverage: entry.coverage ?? null,
|