ticketlens 0.38.31 → 0.38.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/ticketlens.mjs +5 -22
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +98 -1
- package/skills/jtb/hooks/recall-nudge-lib.mjs +44 -0
- package/skills/jtb/hooks/recall-nudge-stop.mjs +12 -2
- package/skills/jtb/scripts/fetch-ticket.mjs +51 -40
- package/skills/jtb/scripts/lib/help.mjs +18 -10
- package/skills/jtb/scripts/lib/mcp-server.mjs +127 -186
- package/skills/jtb/scripts/lib/mcp-tool-schemas.mjs +257 -0
- package/skills/jtb/scripts/lib/run-history.mjs +50 -0
- package/skills/jtb/scripts/lib/secret-scanner.mjs +75 -14
package/README.md
CHANGED
|
@@ -453,7 +453,7 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
453
453
|
|
|
454
454
|
**Removing a note:** `ticketlens note delete --id="..." [--ticket=KEY]` removes a note from your local vault. Local only — if it was already pushed to a team, teammates who pulled it keep their copy; deleting it there too is a manager action from the Console (Admin > Recall).
|
|
455
455
|
|
|
456
|
-
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `fetch`, `triage`, `compliance`, `doctor`, `recall_add`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `ticket_create` as native tools — any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same license gate per tool, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. `fetch` and `
|
|
456
|
+
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `fetch`, `triage`, `compliance`, `review`, `standup`, `pr`, `stats`, `history`, `collisions`, `doctor`, `recall_add`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `ticket_create` as native tools — any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same license gate per tool, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. `fetch`, `doctor`, and `standup` are Free; `triage`'s base scan is Free with some options gated Pro/Team, same as the CLI (`ticketlens triage --help`); `compliance` and `pr` are Free, sharing a 3-checks/month cap on their requirements-coverage section, Pro unlimited; `review` is Free for branch/files/ticket context, with its coverage/focus section requiring Pro as a plain license check — it does not draw from that same monthly counter; `stats` is Free with a 7-day lookback cap, Pro extends it to 30 days, same split as the CLI (`ticketlens stats --help`); `history` reads local triage history only (zero network) and requires Pro; `collisions` requires `ticketlens login` (Console access) plus a Team license; every other tool needs Pro. Point your harness's MCP config at it: `{ "command": "ticketlens", "args": ["mcp"] }` — or run `ticketlens mcp install` in a project to write that entry into its `.mcp.json` for you (creates the file if it doesn't exist, merges in if it does — never touches any other entry already there; `--dry-run` to preview first).
|
|
457
457
|
|
|
458
458
|
`note add`'s save confirmation and `recall`'s search results are styled by default in a terminal; add `--plain` to either for bare, pipe-safe output. `recall` always shows each note's file ID (e.g. `[1784135399545-fe01c4.md]`) so you can open it directly (`cat ~/.ticketlens/recall/<PREFIX>/<id>`), or pass `--full` to print the full body content inline instead. Each result shows a relative time (`2h ago`, `3d ago`) rather than a bare date — the full-precision timestamp is always in the note file's own frontmatter.
|
|
459
459
|
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { deleteProfile, loadProfiles, saveCredentialKey, resolveRecallStrictness
|
|
|
20
20
|
import { run as runCache } from '../skills/jtb/scripts/lib/cache-manager.mjs';
|
|
21
21
|
import { runDoctor } from '../skills/jtb/scripts/lib/doctor-command.mjs';
|
|
22
22
|
import {
|
|
23
|
-
printHelp, printProfiles,
|
|
23
|
+
printHelp, printProfiles,
|
|
24
24
|
printLoginHelp, printLogoutHelp, printSyncHelp,
|
|
25
25
|
printActivateHelp, printLicenseHelp, printDeleteHelp,
|
|
26
26
|
printProfilesHelp, printScheduleHelp,
|
|
@@ -142,28 +142,11 @@ switch (command) {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
case 'history': {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (!ticketKey || ticketKey.startsWith('-')) {
|
|
149
|
-
process.stderr.write('Usage: ticketlens history TICKET-KEY\n');
|
|
145
|
+
const { runHistory } = await import('../skills/jtb/scripts/lib/run-history.mjs');
|
|
146
|
+
runHistory(cmdArgs).catch(err => {
|
|
147
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
150
148
|
process.exitCode = 1;
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
const { queryTicketHistory } = await import('../skills/jtb/scripts/lib/triage-history.mjs');
|
|
154
|
-
const entries = queryTicketHistory(ticketKey);
|
|
155
|
-
if (entries.length === 0) {
|
|
156
|
-
process.stdout.write(`No triage history found for ${ticketKey}.\n`);
|
|
157
|
-
break;
|
|
158
|
-
}
|
|
159
|
-
const hs = createStyler({ isTTY: process.stdout.isTTY });
|
|
160
|
-
process.stdout.write(`\nHistory for ${hs.bold(ticketKey)} (${entries.length} entries)\n\n`);
|
|
161
|
-
for (const e of entries) {
|
|
162
|
-
const bounce = e.bounced ? hs.yellow(' ⟳ bounced') : '';
|
|
163
|
-
const urg = e.urgency === 'needs-response' ? hs.red(e.urgency) : e.urgency === 'aging' ? hs.yellow(e.urgency) : hs.green(e.urgency);
|
|
164
|
-
process.stdout.write(` ${hs.dim(e.date)} [${e.profile}] ${urg}${bounce} ${hs.dim(e.reason)}\n`);
|
|
165
|
-
}
|
|
166
|
-
process.stdout.write('\n');
|
|
149
|
+
});
|
|
167
150
|
break;
|
|
168
151
|
}
|
|
169
152
|
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.38.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.
|
|
@@ -125,6 +125,103 @@ Requires a Team license and at least one teammate in the same group. Compares yo
|
|
|
125
125
|
|
|
126
126
|
Display the script's stdout directly. No plan mode. Stop here.
|
|
127
127
|
|
|
128
|
+
If this harness has TicketLens's MCP server configured (a tool named `collisions` — often shown as `mcp__ticketlens__collisions` — visible in your tool list), prefer it over the bash form: same Team-license gate, same `ticketlens login` requirement, no shell command to construct. It accepts `json`/`plain`.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
### Stats subcommand
|
|
133
|
+
|
|
134
|
+
If the first argument is `stats`:
|
|
135
|
+
|
|
136
|
+
Run:
|
|
137
|
+
```bash
|
|
138
|
+
ticketlens stats $EXTRA_ARGS
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Where `$EXTRA_ARGS` are any flags passed (e.g. `--days=14 --format=json --profile=acme`).
|
|
142
|
+
|
|
143
|
+
Shows response-time and triage-cadence metrics from local triage history: avg/median response time, clear rate, triage run count, current urgency breakdown. Read-only, entirely local — no network call. `--days=N` (default 7; Free silently caps at 7, Pro allows up to 30); `--format=plain` (default, human-readable table) or `--format=json` (scripting).
|
|
144
|
+
|
|
145
|
+
Display the script's stdout directly. No plan mode. Stop here.
|
|
146
|
+
|
|
147
|
+
If this harness has TicketLens's MCP server configured (a tool named `stats` — often shown as `mcp__ticketlens__stats` — visible in your tool list), prefer it over the bash form: same Free/Pro day-cap split, no shell command to construct. It accepts `profile`/`days`/`format`.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
### History subcommand
|
|
152
|
+
|
|
153
|
+
If the first argument is `history`:
|
|
154
|
+
|
|
155
|
+
Run:
|
|
156
|
+
```bash
|
|
157
|
+
ticketlens history TICKET-KEY
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Requires a ticket key as the first positional argument. Requires a Pro license.
|
|
161
|
+
|
|
162
|
+
Shows the ticket's urgency timeline from local triage history — every prior triage scan that surfaced it, with the urgency level and reason computed at that point in time. Read-only, entirely local — no network call, no other flags.
|
|
163
|
+
|
|
164
|
+
Display the script's stdout directly. No plan mode. Stop here.
|
|
165
|
+
|
|
166
|
+
If this harness has TicketLens's MCP server configured (a tool named `history` — often shown as `mcp__ticketlens__history` — visible in your tool list), prefer it over the bash form: same Pro gate, no shell command to construct. It accepts `ticket`.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
### Standup subcommand
|
|
171
|
+
|
|
172
|
+
If the first argument is `standup`:
|
|
173
|
+
|
|
174
|
+
Run:
|
|
175
|
+
```bash
|
|
176
|
+
ticketlens standup $EXTRA_ARGS
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Where `$EXTRA_ARGS` are any flags passed (e.g. `--since=48 --format=pr --profile=acme`).
|
|
180
|
+
|
|
181
|
+
Summarizes recent git commits grouped by linked ticket key. `--since=N` (hours, default 24) or a git-compatible date expression (`--since="3 days ago"`); `--format=standup` (default) renders a per-ticket standup update, `--format=pr` renders the same grouped commits as PR-body-style markdown. Read-only, fully free tier — no license gate on any option.
|
|
182
|
+
|
|
183
|
+
Display the script's stdout directly. No plan mode. Stop here.
|
|
184
|
+
|
|
185
|
+
If this harness has TicketLens's MCP server configured (a tool named `standup` — often shown as `mcp__ticketlens__standup` — visible in your tool list), prefer it over the bash form: same output, no license gate, no shell command to construct. It accepts `since`/`format`/`profile`.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
### PR subcommand
|
|
190
|
+
|
|
191
|
+
If the first argument is `pr`:
|
|
192
|
+
|
|
193
|
+
Run:
|
|
194
|
+
```bash
|
|
195
|
+
ticketlens pr PROJ-123 $EXTRA_ARGS
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Where `$EXTRA_ARGS` are any flags passed (e.g. `--profile=acme`). Requires a ticket key as the first positional argument.
|
|
199
|
+
|
|
200
|
+
Assembles a ready-to-paste PR description: what changed (from commits linked to the ticket), linked tickets, and — if the ticket has acceptance criteria — a requirements-coverage section. Read-only. The requirements-coverage section reuses the same Free-tier 3-checks/month counter as `--compliance`/`ticketlens compliance` — running `pr` on a ticket with acceptance criteria counts against that shared monthly limit; Pro removes the cap.
|
|
201
|
+
|
|
202
|
+
Display the script's stdout directly. No plan mode. Stop here.
|
|
203
|
+
|
|
204
|
+
If this harness has TicketLens's MCP server configured (a tool named `pr` — often shown as `mcp__ticketlens__pr` — visible in your tool list), prefer it over the bash form: same output, same shared compliance-counter caveat, no shell command to construct. It accepts `ticket`/`profile`.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
### Review subcommand
|
|
209
|
+
|
|
210
|
+
If the first argument is `review`:
|
|
211
|
+
|
|
212
|
+
Run:
|
|
213
|
+
```bash
|
|
214
|
+
ticketlens review $EXTRA_ARGS
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Where `$EXTRA_ARGS` are any flags passed (e.g. `--base=develop --profile=acme`). No required positional argument — operates on the current branch.
|
|
218
|
+
|
|
219
|
+
Assembles PR review context from the current git branch: changed files, linked-ticket summaries from commits/branch name, and (Pro) a requirements-coverage / review-focus section extracted from the diff against acceptance criteria. `--base=BRANCH` (or its alias `--branch=BRANCH`) sets the diff base, auto-detecting `main`/`master`/`develop` when omitted. Read-only — never modifies the tracker or the repo. Free tier gets branch info, changed files, and ticket context; Pro adds the requirements-coverage and review-focus sections — same split as `--compliance`.
|
|
220
|
+
|
|
221
|
+
Display the script's stdout directly. No plan mode. Stop here.
|
|
222
|
+
|
|
223
|
+
If this harness has TicketLens's MCP server configured (a tool named `review` — often shown as `mcp__ticketlens__review` — visible in your tool list), prefer it over the bash form: same tier gate (Free: branch/files/tickets, Pro: +coverage/focus), no shell command to construct. It accepts `base`/`branch`/`profile` — `base` and `branch` are aliases; if both are given, `base` wins.
|
|
224
|
+
|
|
128
225
|
---
|
|
129
226
|
|
|
130
227
|
### Fetch ticket workflow (default)
|
|
@@ -99,6 +99,50 @@ export function hasRecentCapture(cwd, now = Date.now()) {
|
|
|
99
99
|
return lastCaptureAt > 0 && (now - lastCaptureAt) < CAPTURE_FRESHNESS_MS;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Cross-session NAG marker (backlog #14) — same shape as lastCapturePath/
|
|
104
|
+
* readLastCaptureAt/writeLastCaptureAt/hasRecentCapture above, but records
|
|
105
|
+
* "we already blocked once for this directory" instead of "a note was
|
|
106
|
+
* added". Closes a gap those functions never covered: they bridge a
|
|
107
|
+
* session_id rollover only when a REAL capture landed, but a dismissed nag
|
|
108
|
+
* ("genuinely nothing qualified" — the hook's own suggested response) was
|
|
109
|
+
* never remembered anywhere. Since compaction/resume mints a brand-new
|
|
110
|
+
* session_id (see recall-nudge-stop.mjs's docstring), and that resets the
|
|
111
|
+
* per-session_id stopChecked gate, one long working session with several
|
|
112
|
+
* compaction cycles could get nagged repeatedly for the same still-ongoing
|
|
113
|
+
* work — a real, reported friction source (backlog #14), not hypothetical.
|
|
114
|
+
* Deliberately a separate marker file from lastCapture (not folded into
|
|
115
|
+
* it): a nag and a capture are different facts, and conflating them would
|
|
116
|
+
* make hasRecentCapture's "a real capture landed" guarantee ambiguous.
|
|
117
|
+
* Reuses CAPTURE_FRESHNESS_MS rather than a second magic number — the same
|
|
118
|
+
* 2-hour window is a reasonable proxy for "still the same working session"
|
|
119
|
+
* in both cases, and a distinct constant isn't justified by anything
|
|
120
|
+
* observed so far.
|
|
121
|
+
*/
|
|
122
|
+
export function lastNagPath(cwd) {
|
|
123
|
+
const hash = crypto.createHash('sha256').update(cwd || 'unknown').digest('hex').slice(0, 16);
|
|
124
|
+
return path.join(privateTmpDir(), `lastnag-${hash}.json`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function readLastNagAt(cwd) {
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(fs.readFileSync(lastNagPath(cwd), 'utf8')).lastNagAt ?? 0;
|
|
130
|
+
} catch {
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function writeLastNagAt(cwd, timestamp) {
|
|
136
|
+
try {
|
|
137
|
+
fs.writeFileSync(lastNagPath(cwd), JSON.stringify({ lastNagAt: timestamp }));
|
|
138
|
+
} catch { /* best-effort — losing this marker only costs one extra nag next rollover */ }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function hasRecentNag(cwd, now = Date.now()) {
|
|
142
|
+
const lastNagAt = readLastNagAt(cwd);
|
|
143
|
+
return lastNagAt > 0 && (now - lastNagAt) < CAPTURE_FRESHNESS_MS;
|
|
144
|
+
}
|
|
145
|
+
|
|
102
146
|
/**
|
|
103
147
|
* Reads the transcript (JSONL) and returns simple booleans about what
|
|
104
148
|
* happened this session. Best-effort: any read/parse failure returns all
|
|
@@ -15,7 +15,12 @@
|
|
|
15
15
|
* survive a compaction/resume event — that hands this hook a brand-new
|
|
16
16
|
* session_id, a blank dedup state, AND a blank transcript file, so a real
|
|
17
17
|
* earlier capture becomes invisible. The cross-session lastCapture marker
|
|
18
|
-
* (keyed by cwd, not session_id) is what actually bridges that boundary
|
|
18
|
+
* (keyed by cwd, not session_id) is what actually bridges that boundary
|
|
19
|
+
* for a genuine capture. The parallel lastNag marker (backlog #14) bridges
|
|
20
|
+
* the same boundary for a DISMISSED nag: without it, a session that already
|
|
21
|
+
* got its one nag and was told "genuinely nothing qualified" would nag
|
|
22
|
+
* again after the next compaction/resume rollover, since that dismissal
|
|
23
|
+
* was never recorded anywhere — only a real capture was.
|
|
19
24
|
*
|
|
20
25
|
* Which of the two cases above actually blocks is governed by the active
|
|
21
26
|
* profile's recallStrictness — see recall-nudge-lib.mjs's shouldNag() doc
|
|
@@ -30,7 +35,7 @@
|
|
|
30
35
|
* profile, same as before (backlog #12, design spec §6).
|
|
31
36
|
*/
|
|
32
37
|
|
|
33
|
-
import { readStdinJson, readState, writeState, scanTranscript, hasRecentCapture, writeLastCaptureAt, shouldNag } from './recall-nudge-lib.mjs';
|
|
38
|
+
import { readStdinJson, readState, writeState, scanTranscript, hasRecentCapture, writeLastCaptureAt, hasRecentNag, writeLastNagAt, shouldNag } from './recall-nudge-lib.mjs';
|
|
34
39
|
import { resolveProfile, normalizeRecallStrictness } from '../scripts/lib/profile-resolver.mjs';
|
|
35
40
|
|
|
36
41
|
const input = readStdinJson();
|
|
@@ -61,8 +66,13 @@ if (hasRecentCapture(cwd)) {
|
|
|
61
66
|
process.exit(0); // a real capture landed recently in this same directory, just under a different session_id
|
|
62
67
|
}
|
|
63
68
|
|
|
69
|
+
if (hasRecentNag(cwd)) {
|
|
70
|
+
process.exit(0); // already nagged recently in this same directory, just under a different session_id — a compaction/resume rollover, not a fresh session (backlog #14)
|
|
71
|
+
}
|
|
72
|
+
|
|
64
73
|
state.stopChecked = true;
|
|
65
74
|
writeState(sessionId, state);
|
|
75
|
+
writeLastNagAt(cwd, Date.now());
|
|
66
76
|
|
|
67
77
|
if (sawRecallFlag) {
|
|
68
78
|
process.stderr.write(
|
|
@@ -336,17 +336,20 @@ async function countRecallInjection(recallNotes, configDir, opts) {
|
|
|
336
336
|
}
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
339
|
+
// setInterval won't fire while spawnSync blocks the event loop, so we draw
|
|
340
|
+
// synchronously on update() and only use setInterval during async fetch phases.
|
|
341
|
+
// Both callers pass run()'s errStream, so the spinner shares the same isTTY and
|
|
342
|
+
// write target as everything else the command emits: the real process.stderr for
|
|
343
|
+
// a CLI user, the captured printErr under MCP (where the draws must not leak).
|
|
344
|
+
function makeSpinner(s, { isTTY, write }) {
|
|
345
|
+
if (!isTTY) return { update: () => {}, startAnim: () => {}, done: () => {} };
|
|
343
346
|
const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
|
|
344
347
|
let fi = 0, msg = '', lastW = 0, timerId = null, finished = false;
|
|
345
348
|
|
|
346
349
|
const draw = () => {
|
|
347
350
|
const raw = ` ${s.brand(frames[fi % frames.length])} ${s.dim(msg)}`;
|
|
348
351
|
const vis = raw.replace(/\x1b\[[0-9;]*m/g, '');
|
|
349
|
-
|
|
352
|
+
write(`\r${raw}${' '.repeat(Math.max(0, lastW - vis.length))}`);
|
|
350
353
|
lastW = vis.length;
|
|
351
354
|
};
|
|
352
355
|
|
|
@@ -360,7 +363,7 @@ function makeSpinner(s) {
|
|
|
360
363
|
if (finished) return;
|
|
361
364
|
finished = true;
|
|
362
365
|
if (timerId) { clearInterval(timerId); timerId = null; }
|
|
363
|
-
|
|
366
|
+
write('\r\x1b[2K');
|
|
364
367
|
},
|
|
365
368
|
};
|
|
366
369
|
}
|
|
@@ -485,10 +488,10 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
485
488
|
}
|
|
486
489
|
|
|
487
490
|
const printFn = opts.print ?? ((chunk) => process.stdout.write(chunk));
|
|
488
|
-
// Mirrors printFn above —
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
491
|
+
// Mirrors printFn above — the bare ticket-fetch, compliance, pr, review, and standup
|
|
492
|
+
// paths all use this now (each has an MCP tool). `ledger` above still writes directly
|
|
493
|
+
// to the real process.stderr (no MCP tool yet); `install-hooks` is CLI-only and never
|
|
494
|
+
// gets one.
|
|
492
495
|
const printErrFn = opts.printErr ?? ((chunk) => process.stderr.write(chunk));
|
|
493
496
|
// Reused on every recursive self-call in the bare-fetch path (profile-prompt retries)
|
|
494
497
|
// so an injected print/printErr survives the retry instead of silently reverting to
|
|
@@ -508,7 +511,9 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
508
511
|
const errStream = { write: printErrFn, isTTY };
|
|
509
512
|
|
|
510
513
|
if (args.includes('--help') || args.includes('-h')) {
|
|
511
|
-
|
|
514
|
+
// stdout's own isTTY, not the `isTTY` above (that one is documented as
|
|
515
|
+
// reflecting process.stderr.isTTY for the stderr-bound helpers below).
|
|
516
|
+
printFetchHelp({ stream: { write: printFn, isTTY: process.stdout.isTTY } });
|
|
512
517
|
return;
|
|
513
518
|
}
|
|
514
519
|
|
|
@@ -549,12 +554,12 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
549
554
|
const { assemblePr } = await import('./lib/pr-assembler.mjs');
|
|
550
555
|
const ticketKeyArg = args[1];
|
|
551
556
|
if (!ticketKeyArg) {
|
|
552
|
-
|
|
557
|
+
printErrFn('Error: "pr" requires a ticket key. Usage: ticketlens pr PROJ-123\n');
|
|
553
558
|
process.exitCode = 1;
|
|
554
559
|
return;
|
|
555
560
|
}
|
|
556
561
|
if (!TICKET_KEY_PATTERN.test(ticketKeyArg)) {
|
|
557
|
-
|
|
562
|
+
printErrFn(`Error: "${ticketKeyArg}" is not a valid ticket key. Expected format: PROJ-123\n`);
|
|
558
563
|
process.exitCode = 1;
|
|
559
564
|
return;
|
|
560
565
|
}
|
|
@@ -568,27 +573,33 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
568
573
|
configDir: resolvedConfigDir,
|
|
569
574
|
profileName: profileNamePr,
|
|
570
575
|
cwd: process.cwd(),
|
|
571
|
-
onWarning: (w) =>
|
|
576
|
+
onWarning: (w) => printErrFn(w + '\n'),
|
|
572
577
|
onProfileNotFound: () => {},
|
|
573
578
|
});
|
|
574
579
|
|
|
575
580
|
const hasAuthPr = connPr.pat || (connPr.email && connPr.apiToken);
|
|
576
581
|
if (!connPr.baseUrl || !hasAuthPr) {
|
|
577
|
-
|
|
582
|
+
printErrFn('Error: No Jira credentials found. Run \'ticketlens init\' or set JIRA_BASE_URL + JIRA_API_TOKEN.\n');
|
|
578
583
|
process.exitCode = 1;
|
|
579
584
|
return;
|
|
580
585
|
}
|
|
581
586
|
|
|
582
587
|
const adapterPr = resolveAdapter(connPr, { fetcher });
|
|
588
|
+
const complianceRunnerPr = opts.runComplianceCheck ?? runComplianceCheck;
|
|
583
589
|
|
|
584
590
|
try {
|
|
585
591
|
const md = await assemblePr(ticketKeyArg, {
|
|
586
592
|
configDir: resolvedConfigDir,
|
|
587
593
|
fetchTicketFn: (key, fOpts = {}) => adapterPr.fetchTicket(key, fOpts),
|
|
594
|
+
// assemblePr's own runComplianceCheckFn default has no stream override
|
|
595
|
+
// (compliance-checker.mjs's `stream = process.stderr`), so the Pro-gate
|
|
596
|
+
// upgrade prompt would leak to real stderr unless threaded here — same
|
|
597
|
+
// treatment as the compliance dispatch block above (`stream: errStream`).
|
|
598
|
+
runComplianceCheckFn: (o) => complianceRunnerPr({ ...o, stream: errStream }),
|
|
588
599
|
});
|
|
589
600
|
printFn(md + '\n');
|
|
590
601
|
} catch (err) {
|
|
591
|
-
|
|
602
|
+
printErrFn(`Error: ${err.message}\n`);
|
|
592
603
|
process.exitCode = 1;
|
|
593
604
|
}
|
|
594
605
|
return;
|
|
@@ -710,7 +721,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
710
721
|
const execFn = opts.execFn ?? spawnSync;
|
|
711
722
|
const cwd = process.cwd();
|
|
712
723
|
|
|
713
|
-
const sErr = createStyler({ isTTY
|
|
724
|
+
const sErr = createStyler({ isTTY });
|
|
714
725
|
|
|
715
726
|
// Validate flags before any git work
|
|
716
727
|
const reviewFlags = args.slice(1);
|
|
@@ -721,28 +732,28 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
721
732
|
// Detect --profile-NAME typo (dash instead of =)
|
|
722
733
|
const profileDashM = flag.match(/^--profile-(.+)$/);
|
|
723
734
|
if (profileDashM) {
|
|
724
|
-
|
|
735
|
+
printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--profile=${profileDashM[1]}`)}?\n`);
|
|
725
736
|
process.exitCode = 1;
|
|
726
737
|
return;
|
|
727
738
|
}
|
|
728
739
|
const baseDashM = flag.match(/^--base-(.+)$/);
|
|
729
740
|
if (baseDashM) {
|
|
730
|
-
|
|
741
|
+
printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--base=${baseDashM[1]}`)}?\n`);
|
|
731
742
|
process.exitCode = 1;
|
|
732
743
|
return;
|
|
733
744
|
}
|
|
734
745
|
const branchDashM = flag.match(/^--branch-(.+)$/);
|
|
735
746
|
if (branchDashM) {
|
|
736
|
-
|
|
747
|
+
printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--branch=${branchDashM[1]}`)}?\n`);
|
|
737
748
|
process.exitCode = 1;
|
|
738
749
|
return;
|
|
739
750
|
}
|
|
740
|
-
|
|
751
|
+
printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Usage: ${sErr.cyan('ticketlens review [--base=BRANCH] [--branch=BRANCH] [--profile=NAME]')}\n`);
|
|
741
752
|
process.exitCode = 1;
|
|
742
753
|
return;
|
|
743
754
|
}
|
|
744
755
|
|
|
745
|
-
const spinner = makeSpinner(sErr);
|
|
756
|
+
const spinner = makeSpinner(sErr, errStream);
|
|
746
757
|
const stderrNotes = [];
|
|
747
758
|
|
|
748
759
|
// Resolve base branch: --base=BRANCH or auto-detect main/master/develop
|
|
@@ -756,7 +767,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
756
767
|
const verifyR = execFn('git', ['rev-parse', '--verify', baseBranch], { encoding: 'utf8', cwd, timeout: 5_000 });
|
|
757
768
|
if (verifyR.status !== 0) {
|
|
758
769
|
spinner.done();
|
|
759
|
-
|
|
770
|
+
printErrFn(`${sErr.red('✖')} Branch "${baseBranch}" not found in this repository.\n`);
|
|
760
771
|
process.exitCode = 1;
|
|
761
772
|
return;
|
|
762
773
|
}
|
|
@@ -794,10 +805,10 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
794
805
|
const profiles = loadProfiles(resolvedConfigDir);
|
|
795
806
|
if (!profiles?.profiles?.[profileNameR]) {
|
|
796
807
|
spinner.done();
|
|
797
|
-
|
|
808
|
+
printErrFn(`${sErr.red('✖')} Profile "${profileNameR}" not found.\n`);
|
|
798
809
|
const names = Object.keys(profiles?.profiles ?? {});
|
|
799
|
-
if (names.length > 0)
|
|
800
|
-
else
|
|
810
|
+
if (names.length > 0) printErrFn(` ${sErr.dim('Available:')} ${names.join(', ')}\n`);
|
|
811
|
+
else printErrFn(` Run ${sErr.cyan('ticketlens init')} to configure a profile.\n`);
|
|
801
812
|
process.exitCode = 1;
|
|
802
813
|
return;
|
|
803
814
|
}
|
|
@@ -839,8 +850,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
839
850
|
}
|
|
840
851
|
|
|
841
852
|
spinner.done();
|
|
842
|
-
for (const note of stderrNotes)
|
|
843
|
-
if (stderrNotes.length > 0)
|
|
853
|
+
for (const note of stderrNotes) printErrFn(note + '\n');
|
|
854
|
+
if (stderrNotes.length > 0) printErrFn('\n');
|
|
844
855
|
|
|
845
856
|
const isLic = opts.isLicensedFn ?? ((tier) => isLicensed(tier, resolvedConfigDir));
|
|
846
857
|
const assembleFn = opts.assemblePrReviewFn ?? assemblePrReview;
|
|
@@ -867,7 +878,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
867
878
|
const resolvedConfigDir = configDir ?? (await import('./lib/config.mjs')).DEFAULT_CONFIG_DIR;
|
|
868
879
|
const execFn = opts.execFn ?? spawnSync;
|
|
869
880
|
const cwd = process.cwd();
|
|
870
|
-
const sErr = createStyler({ isTTY
|
|
881
|
+
const sErr = createStyler({ isTTY });
|
|
871
882
|
|
|
872
883
|
const standupFlags = args.slice(1);
|
|
873
884
|
|
|
@@ -878,29 +889,29 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
878
889
|
|
|
879
890
|
const sinceDashM = flag.match(/^--since-(.+)$/);
|
|
880
891
|
if (sinceDashM) {
|
|
881
|
-
|
|
892
|
+
printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--since=${sinceDashM[1]}`)}?\n`);
|
|
882
893
|
process.exitCode = 1;
|
|
883
894
|
return;
|
|
884
895
|
}
|
|
885
896
|
const formatDashM = flag.match(/^--format-(.+)$/);
|
|
886
897
|
if (formatDashM) {
|
|
887
|
-
|
|
898
|
+
printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--format=${formatDashM[1]}`)}?\n`);
|
|
888
899
|
process.exitCode = 1;
|
|
889
900
|
return;
|
|
890
901
|
}
|
|
891
902
|
const profileDashM = flag.match(/^--profile-(.+)$/);
|
|
892
903
|
if (profileDashM) {
|
|
893
|
-
|
|
904
|
+
printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--profile=${profileDashM[1]}`)}?\n`);
|
|
894
905
|
process.exitCode = 1;
|
|
895
906
|
return;
|
|
896
907
|
}
|
|
897
908
|
const formatValueM = flag.match(/^--format=(.+)$/);
|
|
898
909
|
if (formatValueM) {
|
|
899
|
-
|
|
910
|
+
printErrFn(`${sErr.red('✖')} Invalid --format value: "${formatValueM[1]}". Expected: standup or pr\n`);
|
|
900
911
|
process.exitCode = 1;
|
|
901
912
|
return;
|
|
902
913
|
}
|
|
903
|
-
|
|
914
|
+
printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Usage: ${sErr.cyan('ticketlens standup [--since=N] [--format=standup|pr] [--profile=NAME]')}\n`);
|
|
904
915
|
process.exitCode = 1;
|
|
905
916
|
return;
|
|
906
917
|
}
|
|
@@ -921,16 +932,16 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
921
932
|
if (profileNameS) {
|
|
922
933
|
const profiles = loadProfiles(resolvedConfigDir);
|
|
923
934
|
if (!profiles?.profiles?.[profileNameS]) {
|
|
924
|
-
|
|
935
|
+
printErrFn(`${sErr.red('✖')} Profile "${profileNameS}" not found.\n`);
|
|
925
936
|
const names = Object.keys(profiles?.profiles ?? {});
|
|
926
|
-
if (names.length > 0)
|
|
927
|
-
else
|
|
937
|
+
if (names.length > 0) printErrFn(` ${sErr.dim('Available:')} ${names.join(', ')}\n`);
|
|
938
|
+
else printErrFn(` Run ${sErr.cyan('ticketlens init')} to configure a profile.\n`);
|
|
928
939
|
process.exitCode = 1;
|
|
929
940
|
return;
|
|
930
941
|
}
|
|
931
942
|
}
|
|
932
943
|
|
|
933
|
-
const spinner = makeSpinner(sErr);
|
|
944
|
+
const spinner = makeSpinner(sErr, errStream);
|
|
934
945
|
const stderrNotes = [];
|
|
935
946
|
|
|
936
947
|
spinner.update('Scanning git log…');
|
|
@@ -977,8 +988,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
977
988
|
}
|
|
978
989
|
|
|
979
990
|
spinner.done();
|
|
980
|
-
for (const note of stderrNotes)
|
|
981
|
-
if (stderrNotes.length > 0)
|
|
991
|
+
for (const note of stderrNotes) printErrFn(note + '\n');
|
|
992
|
+
if (stderrNotes.length > 0) printErrFn('\n');
|
|
982
993
|
|
|
983
994
|
const isPlain = standupFlags.includes('--plain');
|
|
984
995
|
const assembleStandupFn = opts.assembleStandupFn ?? assembleStandup;
|
|
@@ -645,17 +645,25 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
645
645
|
` ${s.bold(s.brand('ticketlens'))} ${s.bold('mcp')}`,
|
|
646
646
|
'',
|
|
647
647
|
` Start an MCP (Model Context Protocol) stdio server exposing CLI actions as`,
|
|
648
|
-
` native tools — ${s.cyan('fetch')}, ${s.cyan('triage')}, ${s.cyan('compliance')}, ${s.cyan('
|
|
649
|
-
` ${s.cyan('
|
|
648
|
+
` native tools — ${s.cyan('fetch')}, ${s.cyan('triage')}, ${s.cyan('compliance')}, ${s.cyan('review')}, ${s.cyan('standup')}, ${s.cyan('pr')}, ${s.cyan('stats')}, ${s.cyan('history')},`,
|
|
649
|
+
` ${s.cyan('collisions')}, ${s.cyan('doctor')}, ${s.cyan('recall_add')}, ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')}, ${s.cyan('ticket_transition')},`,
|
|
650
|
+
` ${s.cyan('ticket_assign')}, ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')}, ${s.cyan('ticket_update')}, ${s.cyan('ticket_create')} — for any`,
|
|
650
651
|
` MCP-compatible AI harness, not just Claude Code. Thin adapter over the same`,
|
|
651
|
-
` code as ${s.cyan('TICKET-KEY')}/${s.cyan('doctor')}/${s.cyan('triage')}/${s.cyan('compliance')}/${s.cyan('
|
|
652
|
-
`
|
|
653
|
-
`
|
|
654
|
-
`
|
|
655
|
-
`
|
|
656
|
-
`
|
|
657
|
-
`
|
|
658
|
-
`
|
|
652
|
+
` code as ${s.cyan('TICKET-KEY')}/${s.cyan('doctor')}/${s.cyan('triage')}/${s.cyan('compliance')}/${s.cyan('review')}/${s.cyan('standup')}/${s.cyan('pr')}/${s.cyan('stats')}/${s.cyan('history')}/${s.cyan('collisions')}/`,
|
|
653
|
+
` ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')}/${s.cyan('link')}/${s.cyan('update')}/${s.cyan('create')} above.`,
|
|
654
|
+
` ${s.cyan('fetch')}, ${s.cyan('doctor')}, and ${s.cyan('standup')} are Free; ${s.cyan('triage')} is Free with some Pro/Team-gated`,
|
|
655
|
+
` options (see ${s.cyan('ticketlens triage --help')}); ${s.cyan('compliance')} and ${s.cyan('pr')} are Free, sharing a`,
|
|
656
|
+
` 3-checks/month cap on their requirements-coverage section, Pro unlimited;`,
|
|
657
|
+
` ${s.cyan('review')} is Free for branch/files/ticket context — its coverage/focus section`,
|
|
658
|
+
` requires Pro as a plain license check, not a draw on that same counter;`,
|
|
659
|
+
` ${s.cyan('stats')} is Free with a 7-day lookback, Pro extends it to 30 days, same split`,
|
|
660
|
+
` as ${s.cyan('ticketlens stats --help')}; ${s.cyan('history')} is entirely local and requires Pro;`,
|
|
661
|
+
` ${s.cyan('collisions')} requires ${s.cyan('ticketlens login')} (Console access) plus a Team license — every`,
|
|
662
|
+
` other tool needs Pro. ${s.cyan('ticket_transition')} is destructive when called with`,
|
|
663
|
+
` \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently self-assign only;`,
|
|
664
|
+
` ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the source issue as a`,
|
|
665
|
+
` duplicate — different semantics than Jira/Linear's relationship-only add;`,
|
|
666
|
+
` ${s.cyan('ticket_update')} has no priority field on GitHub and can partially succeed;`,
|
|
659
667
|
` ${s.cyan('ticket_create')} has no ticket key to target — --profile/the default profile picks the`,
|
|
660
668
|
` tracker, and it fabricates a real item, the highest blast radius of this family.`,
|
|
661
669
|
` Long-running — exits when the client closes stdin.`,
|