ticketlens 0.38.48 → 0.38.50
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 +2 -2
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +8 -5
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +7 -3
- package/skills/jtb/scripts/lib/api-utils.mjs +1 -1
- package/skills/jtb/scripts/lib/consensus-checker.mjs +78 -157
- package/skills/jtb/scripts/lib/help.mjs +9 -8
- package/skills/jtb/scripts/lib/mcp-tool-schemas.mjs +1 -1
- package/skills/jtb/scripts/lib/summarizer.mjs +4 -1
- package/skills/jtb/scripts/lib/ticket-command.mjs +9 -2
package/README.md
CHANGED
|
@@ -300,7 +300,7 @@ ticketlens compliance <TICKET-KEY> --consensus -y # Same, skipping the cost-conf
|
|
|
300
300
|
|
|
301
301
|
Runs the same compliance check as `ticketlens CNV1-2 --compliance` but as a dedicated subcommand — useful when you want to check compliance without fetching the full ticket brief. Free accounts get 3 checks per month; Pro is unlimited.
|
|
302
302
|
|
|
303
|
-
`--consensus` (Pro) replaces the local deterministic matcher with independent reviews from
|
|
303
|
+
`--consensus` (Pro) replaces the local deterministic matcher with independent reviews from your team's AI provider pool, reconciled by majority vote after a disagreement-triggered refinement round. Requires `ticketlens login` and a "consensus" role set up at Console > Admin > AI Roles, with 2+ providers attached from your team's shared registry (Console > Admin > AI Provider Pool — any title/key/endpoint/model, not a fixed list). This is the only compliance path that sends your diff off-machine — to TicketLens's backend, which fans out to each provider (keys are encrypted server-side and never sent to the CLI). The diff is scanned for secrets before anything is sent; a detected secret blocks the run with no AI call made. Prompts for confirmation before spending your team's API credits unless `-y`/`--yes` is passed.
|
|
304
304
|
|
|
305
305
|
---
|
|
306
306
|
|
|
@@ -460,7 +460,7 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
460
460
|
|
|
461
461
|
**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).
|
|
462
462
|
|
|
463
|
-
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `fetch`, `triage`, `compliance`, `review`, `standup`, `pr`, `stats`, `issue_types`, `history`, `collisions`, `ledger`, `doctor`, `recall_add`, `recall_update`, `recall_delete`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `ticket_create` as native tools — every CLI action now has an MCP tool, so 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; `compliance` additionally accepts `consensus: true` (Pro) to replace the local deterministic matcher with a multi-agent AI review
|
|
463
|
+
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `fetch`, `triage`, `compliance`, `review`, `standup`, `pr`, `stats`, `issue_types`, `history`, `collisions`, `ledger`, `doctor`, `recall_add`, `recall_update`, `recall_delete`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `ticket_create` as native tools — every CLI action now has an MCP tool, so 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; `compliance` additionally accepts `consensus: true` (Pro) to replace the local deterministic matcher with a multi-agent AI review, run server-side against a "consensus" role configured at Console > Admin > AI Roles (2+ providers from the team's shared pool), and always implies `--yes` under MCP since there's no TTY for the cost-confirmation prompt; `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`); `issue_types` is Free and Jira-only — pre-fetches and caches a profile's real creatable projects and issue types ahead of a `ticket_create` attempt, sharing its cache with that tool's own reactive enrichment; Linear/GitHub profiles get a clear "not available" instead of an empty result; `history` reads local triage history only (zero network) and requires Pro; `collisions` requires `ticketlens login` (Console access) plus a Team license; `ledger` exports the local, signed compliance audit trail (zero network) and requires Pro; every other tool needs Pro. `recall_update` overwrites an existing Recall note's body — internal plumbing for the note quality loop, not typically called directly; its `attachments` array appends new files to whatever the note already has, same as `recall_add`'s, never replacing existing ones. `recall_delete` is destructive and local-vault-only — requires `confirm: true` alongside `id` to actually execute; there is no interactive y/N prompt under MCP (no real terminal to prompt against), so omitting it always fails rather than silently blocking. 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).
|
|
464
464
|
|
|
465
465
|
`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.
|
|
466
466
|
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.43.1 -->
|
|
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.
|
|
@@ -424,6 +424,8 @@ ticketlens assign PROD-1234 --to=me # assign to yoursel
|
|
|
424
424
|
ticketlens duplicates PROD-1234 # find likely duplicates — read-only
|
|
425
425
|
ticketlens link PROD-1234 PROD-5678 # list valid link types — read-only
|
|
426
426
|
ticketlens link PROD-1234 PROD-5678 --type="Duplicate" --confirm # execute the link
|
|
427
|
+
# Jira's list shows the real resulting sentence per type (e.g. "Blocks — PROD-1234 blocks PROD-5678")
|
|
428
|
+
# so direction is never a guess — GitHub/Linear show just the type name (single relation, no ambiguity)
|
|
427
429
|
ticketlens update PROD-1234 --title="Fix login on mobile" # update title/description/labels/priority
|
|
428
430
|
ticketlens update PROD-1234 --add-labels=urgent --remove-labels=stale
|
|
429
431
|
ticketlens create --project=PROD --type="Task" --summary="Fix login on mobile" # create a new ticket
|
|
@@ -539,10 +541,11 @@ If this harness has TicketLens's MCP server configured (a tool named `compliance
|
|
|
539
541
|
### --consensus: Multi-Agent AI Review (standalone command only, Pro)
|
|
540
542
|
`ticketlens compliance PROJ-123 --consensus` replaces the local deterministic keyword-diff matcher with a multi-agent AI review, for a higher-confidence check on requirements that plain keyword matching tends to misjudge (paraphrased requirements, negative conditions, cross-file logic).
|
|
541
543
|
|
|
542
|
-
- **
|
|
543
|
-
- Each
|
|
544
|
-
- Prompts for confirmation before making any API calls (real cost against your
|
|
545
|
-
-
|
|
544
|
+
- **Requires `ticketlens login`** and a **consensus role** configured at Console > Admin > AI Roles, with 2+ providers attached from your team's shared registry (Console > Admin > AI Provider Pool — any title/key/endpoint/model your team wants, not a fixed vendor list; managed by team managers, selected per-role by each teammate). Provider keys are encrypted server-side and never sent to the CLI, so the actual AI calls run on TicketLens's backend, not from your machine directly — **this is the one path in TicketLens where the diff leaves your machine, to TicketLens's own backend, which fans out to each provider.** Before anything is sent, the diff runs through the same secret scanner Recall notes use — a detected secret blocks the run entirely, before any AI call.
|
|
545
|
+
- Each attached provider independently reviews every requirement; where agents disagree, a second refinement round shows each agent its peers' (anonymized) verdicts and lets it reconsider; final verdicts are reconciled by majority vote, ties broken toward the stricter verdict. The report always shows the full per-agent breakdown, including any round-1→round-2 change.
|
|
546
|
+
- Prompts for confirmation before making any API calls (real cost against your team's provider keys) unless `-y`/`--yes` is passed. Over MCP, `consensus: true` always implies yes (no TTY to prompt).
|
|
547
|
+
- The now-superseded first version (0.38.48, same-day) read `~/.ticketlens/credentials.json` and called Anthropic/OpenAI/Groq directly from the CLI — deprecated before any stable release once the dynamic, Console-managed provider registry replaced it.
|
|
548
|
+
- `Phase 2` (not yet built): non-`anthropic`/`openai_compatible` provider shapes beyond the two `provider_type`s the registry supports today. `Phase 1.5` (not yet built): scored iterative refinement with a stronger-model arbiter judging consensus acceptability, instead of plain majority vote.
|
|
546
549
|
|
|
547
550
|
### Ledger export
|
|
548
551
|
`ticketlens ledger [--format=json|csv]` exports the same local, signed compliance ledger these checks write to — entirely local, no network call. `[Pro]`. If this harness has TicketLens's MCP server configured (a tool named `ledger` — often shown as `mcp__ticketlens__ledger` — visible in your tool list), prefer it over the bash form: same tier gate, same export, just no shell command to construct or stdout to parse. It accepts `format` (`json`/`csv`, defaults to `json`).
|
|
@@ -109,12 +109,16 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
109
109
|
/**
|
|
110
110
|
* Always fetched fresh — link type names are per-instance customizable
|
|
111
111
|
* in Jira, same "never trust a stale list" principle as getTransitions.
|
|
112
|
-
* Returns
|
|
113
|
-
*
|
|
112
|
+
* Returns name+inward+outward (backlog #31), not just the bare name:
|
|
113
|
+
* a bare name forces the caller to guess which end gets which phrase,
|
|
114
|
+
* which is exactly what produced 5 recurring "direction inverted"
|
|
115
|
+
* reports despite linkTo()'s own outward/inward mapping being correct
|
|
116
|
+
* and tested. GitHub/Linear keep returning plain strings — single
|
|
117
|
+
* relation type, no phrase ambiguity there.
|
|
114
118
|
*/
|
|
115
119
|
async getLinkTypes(opts = {}) {
|
|
116
120
|
const types = await getIssueLinkTypes({ ...base, ...opts });
|
|
117
|
-
return types.map(t => t.name);
|
|
121
|
+
return types.map(t => ({ name: t.name, inward: t.inward, outward: t.outward }));
|
|
118
122
|
},
|
|
119
123
|
|
|
120
124
|
/**
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Centralised here to avoid triplicating the regex and warning logic.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
export const DEFAULT_API_BASE = '
|
|
6
|
+
export const DEFAULT_API_BASE = 'http://api.ticketlens.test';
|
|
7
7
|
export const DEFAULT_SITE_BASE = 'https://ticketlens.app';
|
|
8
8
|
|
|
9
9
|
// Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
|
|
@@ -1,82 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ticketlens compliance TICKET --consensus — thin client over POST /v1/consensus.
|
|
3
|
+
*
|
|
4
|
+
* Superseded local-BYOK version (0.38.48): read ~/.ticketlens/credentials.json,
|
|
5
|
+
* called Anthropic/OpenAI/Groq directly from the CLI. Deprecated same session it
|
|
6
|
+
* shipped — provider keys now live in the group-shared, dynamic AiProviderPool
|
|
7
|
+
* registry (Console > Admin > AI Provider Pool / AI Roles), encrypted server-side
|
|
8
|
+
* and never sent to the CLI, so the actual AI calls must happen on the backend.
|
|
9
|
+
* The diff and requirements still only ever leave the machine to reach
|
|
10
|
+
* TicketLens's own backend now (not straight to each vendor) — see
|
|
11
|
+
* ConsensusController.php for the round-1/round-2/reconcile algorithm, a direct
|
|
12
|
+
* port of what used to run here.
|
|
13
|
+
*/
|
|
1
14
|
import { isLicensed, showUpgradePrompt } from './license.mjs';
|
|
2
15
|
import { extractRequirements } from './requirement-extractor.mjs';
|
|
3
16
|
import { findLinkedCommits } from './commit-linker.mjs';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
17
|
+
import { readCliToken } from './cli-auth.mjs';
|
|
18
|
+
import { apiBase } from './api-utils.mjs';
|
|
6
19
|
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
7
20
|
import { createStyler } from './ansi.mjs';
|
|
8
21
|
import { STATUS_ICON, statusColor, coverageColor } from './compliance-checker.mjs';
|
|
9
|
-
import { scanForSecrets } from './secret-scanner.mjs';
|
|
10
22
|
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
const MAX_TOKENS = 512;
|
|
16
|
-
// Each "requirement text | STATUS" response line runs ~20-40 tokens — scale the
|
|
17
|
-
// floor up for tickets with many acceptance criteria so the model's per-requirement
|
|
18
|
-
// list doesn't get cut off mid-response (a truncated response silently reads as
|
|
19
|
-
// NOT_FOUND for every unparsed requirement via parseVerdicts, not as an error).
|
|
20
|
-
const TOKENS_PER_REQUIREMENT = 40;
|
|
21
|
-
|
|
22
|
-
export function getConfiguredProviders(credentials) {
|
|
23
|
-
if (!credentials) return [];
|
|
24
|
-
return PROVIDER_ORDER.filter(p => !!credentials[PROVIDER_KEY_FIELD[p]]);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Ports ComplianceController::parseAnalysis's line-matching convention to JS. */
|
|
28
|
-
export function parseVerdicts(requirements, rawAnalysis) {
|
|
29
|
-
const lines = (rawAnalysis ?? '').split('\n');
|
|
30
|
-
return requirements.map(req => {
|
|
31
|
-
const needle = req.slice(0, 20).toLowerCase();
|
|
32
|
-
let status = 'NOT_FOUND';
|
|
33
|
-
for (const line of lines) {
|
|
34
|
-
if (!line.toLowerCase().includes(needle)) continue;
|
|
35
|
-
const upper = line.toUpperCase();
|
|
36
|
-
if (upper.includes('PARTIAL')) status = 'PARTIAL';
|
|
37
|
-
else if (upper.includes('FOUND') && !upper.includes('NOT_FOUND')) status = 'FOUND';
|
|
38
|
-
break;
|
|
39
|
-
}
|
|
40
|
-
return status;
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Majority vote across agents' final verdicts for one requirement; ties go to the stricter verdict. */
|
|
45
|
-
export function reconcileRequirement(verdicts) {
|
|
46
|
-
const counts = new Map();
|
|
47
|
-
for (const v of verdicts) counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
48
|
-
const maxCount = Math.max(...counts.values());
|
|
49
|
-
const topStatuses = [...counts.entries()].filter(([, c]) => c === maxCount).map(([status]) => status);
|
|
50
|
-
if (topStatuses.length === 1) return topStatuses[0];
|
|
51
|
-
return topStatuses.reduce((strictest, s) => (STRICTNESS[s] < STRICTNESS[strictest] ? s : strictest));
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function buildRound1Prompt(diff, requirements) {
|
|
55
|
-
return 'You are a compliance checker. Given this code diff, evaluate whether each requirement listed is addressed.\n\n'
|
|
56
|
-
+ `Diff:\n${diff || '(no diff available)'}\n\n`
|
|
57
|
-
+ 'Requirements to check:\n'
|
|
58
|
-
+ requirements.map(r => `- ${r}`).join('\n')
|
|
59
|
-
+ "\n\nFor each requirement, respond with: FOUND, PARTIAL, or NOT_FOUND. One per line, format: '<requirement> | <status>'.";
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function buildRound2Prompt(diff, disagreedItems, selfProvider, successful1) {
|
|
63
|
-
const peers = successful1.filter(r => r.provider !== selfProvider);
|
|
64
|
-
const lines = [
|
|
65
|
-
'You previously reviewed a code diff against a set of requirements. Other independent',
|
|
66
|
-
'reviewers disagreed with you on some items below. Reconsider only these, in light of',
|
|
67
|
-
"their assessments, and respond again in the same format.",
|
|
68
|
-
'',
|
|
69
|
-
`Diff:\n${diff || '(no diff available)'}`,
|
|
70
|
-
'',
|
|
71
|
-
];
|
|
72
|
-
for (const { requirement, index } of disagreedItems) {
|
|
73
|
-
lines.push(`Requirement: ${requirement}`);
|
|
74
|
-
peers.forEach((peer, i) => lines.push(` Reviewer ${i + 1} said: ${peer.verdicts[index]}`));
|
|
75
|
-
lines.push('');
|
|
76
|
-
}
|
|
77
|
-
lines.push("For each requirement above, respond with: FOUND, PARTIAL, or NOT_FOUND. One per line, format: '<requirement> | <status>'.");
|
|
78
|
-
return lines.join('\n');
|
|
79
|
-
}
|
|
23
|
+
const ROLES_PATH = '/v1/ai-provider-roles';
|
|
24
|
+
const CONSENSUS_PATH = '/v1/consensus';
|
|
25
|
+
const ROLES_TIMEOUT_MS = 10_000;
|
|
26
|
+
const CONSENSUS_TIMEOUT_MS = 90_000; // two AI rounds across N providers — real work, not a quick API call
|
|
80
27
|
|
|
81
28
|
/** Interactive y/N cost-confirmation gate — same non-interactive fallback shape as confirmDestructive. */
|
|
82
29
|
async function confirmCost(providerCount, { stream = process.stderr, stdin = process.stdin } = {}) {
|
|
@@ -84,7 +31,7 @@ async function confirmCost(providerCount, { stream = process.stderr, stdin = pro
|
|
|
84
31
|
stream.write(' Non-interactive mode: pass --yes/-y to run --consensus without a prompt.\n');
|
|
85
32
|
return false;
|
|
86
33
|
}
|
|
87
|
-
stream.write(` --consensus will
|
|
34
|
+
stream.write(` --consensus will run ${providerCount} AI review(s) via your team's provider pool. Continue? y/N `);
|
|
88
35
|
return new Promise(resolve => {
|
|
89
36
|
stdin.setRawMode(true);
|
|
90
37
|
stdin.resume();
|
|
@@ -110,12 +57,12 @@ function formatNoCriteriaReport(ticketKey, s) {
|
|
|
110
57
|
].join('\n');
|
|
111
58
|
}
|
|
112
59
|
|
|
113
|
-
function formatConsensusReport({ ticketKey, results,
|
|
60
|
+
function formatConsensusReport({ ticketKey, results, perAgent, disagreedCount, s }) {
|
|
114
61
|
const lines = [
|
|
115
62
|
'',
|
|
116
63
|
` Consensus Compliance Check — ${s.brand(s.bold(ticketKey))}`,
|
|
117
64
|
` ${s.dim('─'.repeat(50))}`,
|
|
118
|
-
` ${s.dim(`${
|
|
65
|
+
` ${s.dim(`${perAgent.length} agents: ${perAgent.map(a => a.title).join(', ')}`)}`,
|
|
119
66
|
'',
|
|
120
67
|
];
|
|
121
68
|
|
|
@@ -126,19 +73,27 @@ function formatConsensusReport({ ticketKey, results, coveragePercent, finalVerdi
|
|
|
126
73
|
|
|
127
74
|
lines.push('');
|
|
128
75
|
const found = results.filter(r => r.status === 'FOUND').length;
|
|
76
|
+
const coveragePercent = Math.round(((found + results.filter(r => r.status === 'PARTIAL').length * 0.5) / results.length) * 100);
|
|
129
77
|
lines.push(` Coverage: ${coverageColor(coveragePercent, s)(`${coveragePercent}%`)} (${found}/${results.length} requirements found)`);
|
|
130
78
|
if (disagreedCount > 0) {
|
|
131
79
|
lines.push(` ${s.dim(`${disagreedCount} requirement(s) needed a refinement round (agents initially disagreed).`)}`);
|
|
132
80
|
}
|
|
133
81
|
lines.push('');
|
|
134
82
|
lines.push(` ${s.bold('Per-agent breakdown:')}`);
|
|
135
|
-
for (const {
|
|
83
|
+
for (const { title, verdicts, round1Verdicts } of perAgent) {
|
|
136
84
|
const parts = verdicts.map((v, i) => (round1Verdicts[i] !== v ? `${round1Verdicts[i]}→${v}` : v));
|
|
137
|
-
lines.push(` ${s.dim(
|
|
85
|
+
lines.push(` ${s.dim(title)}: ${parts.join(', ')}`);
|
|
138
86
|
}
|
|
139
87
|
lines.push('');
|
|
140
88
|
|
|
141
|
-
return lines.join('\n');
|
|
89
|
+
return { report: lines.join('\n'), coveragePercent };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function fetchJson(url, { fetcher, timeoutMs, ...init }) {
|
|
93
|
+
const res = await fetcher(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
|
94
|
+
let body = null;
|
|
95
|
+
try { body = await res.json(); } catch { /* non-JSON error page — body stays null */ }
|
|
96
|
+
return { ok: res.ok, status: res.status, body };
|
|
142
97
|
}
|
|
143
98
|
|
|
144
99
|
export async function runConsensusCheck({
|
|
@@ -150,14 +105,14 @@ export async function runConsensusCheck({
|
|
|
150
105
|
outStream = process.stdout,
|
|
151
106
|
forceYes = false,
|
|
152
107
|
stdin = process.stdin,
|
|
108
|
+
cliToken,
|
|
153
109
|
isLicensedFn = isLicensed,
|
|
154
110
|
showUpgradeFn = showUpgradePrompt,
|
|
155
111
|
extractRequirementsFn = extractRequirements,
|
|
156
112
|
findLinkedCommitsFn = findLinkedCommits,
|
|
157
|
-
loadCredentialsFn = loadCredentials,
|
|
158
|
-
summarizeFn = summarize,
|
|
159
113
|
confirmCostFn = confirmCost,
|
|
160
|
-
|
|
114
|
+
readCliTokenFn = readCliToken,
|
|
115
|
+
fetcher = globalThis.fetch,
|
|
161
116
|
}) {
|
|
162
117
|
if (!isLicensedFn('pro', configDir)) {
|
|
163
118
|
showUpgradeFn('pro', '--consensus', { stream });
|
|
@@ -171,101 +126,67 @@ export async function runConsensusCheck({
|
|
|
171
126
|
return { report: formatNoCriteriaReport(ticketKey, s), results: [], coveragePercent: 0, noCriteria: true };
|
|
172
127
|
}
|
|
173
128
|
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
// configured AI vendor directly) — scan it before anything else touches the network,
|
|
178
|
-
// the same gate note-command.mjs applies to Recall note bodies before they leave the vault.
|
|
179
|
-
const scan = scanForSecretsFn({ body: diff ?? '' });
|
|
180
|
-
if (scan.rejected) {
|
|
181
|
-
stream.write(` ✖ --consensus blocked — the diff looks like it contains a secret: ${scan.reasons.join(' ')}\n`);
|
|
129
|
+
const token = cliToken ?? readCliTokenFn(configDir);
|
|
130
|
+
if (!token) {
|
|
131
|
+
stream.write(' ✖ --consensus requires a login. Run: ticketlens login\n');
|
|
182
132
|
return null;
|
|
183
133
|
}
|
|
184
|
-
|
|
185
|
-
|
|
134
|
+
|
|
135
|
+
// Pre-flight: know the provider count before prompting for cost, and give a
|
|
136
|
+
// specific, actionable error before ever touching /v1/consensus — the
|
|
137
|
+
// backend re-validates all of this too, this is purely a faster/clearer UX path.
|
|
138
|
+
const rolesRes = await fetchJson(`${apiBase()}${ROLES_PATH}`, {
|
|
139
|
+
fetcher, timeoutMs: ROLES_TIMEOUT_MS,
|
|
140
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
141
|
+
}).catch(err => ({ ok: false, status: 0, body: null, networkError: err }));
|
|
142
|
+
|
|
143
|
+
if (!rolesRes.ok) {
|
|
144
|
+
stream.write(' ✖ Could not reach TicketLens to check your consensus role. Try again, or check your connection.\n');
|
|
145
|
+
return null;
|
|
186
146
|
}
|
|
187
147
|
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
);
|
|
148
|
+
const consensusRole = (rolesRes.body?.roles ?? []).find(r => r.kind === 'consensus');
|
|
149
|
+
if (!consensusRole) {
|
|
150
|
+
stream.write(' ✖ No consensus role configured. Set one up in Console > Admin > AI Providers.\n');
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
if (consensusRole.providers.length < 2) {
|
|
154
|
+
stream.write(` ✖ Your consensus role needs at least 2 providers — currently has ${consensusRole.providers.length}. Add more in Console > Admin > AI Roles.\n`);
|
|
196
155
|
return null;
|
|
197
156
|
}
|
|
198
157
|
|
|
199
158
|
if (!forceYes) {
|
|
200
|
-
const proceed = await confirmCostFn(providers.length, { stream, stdin });
|
|
159
|
+
const proceed = await confirmCostFn(consensusRole.providers.length, { stream, stdin });
|
|
201
160
|
if (!proceed) {
|
|
202
|
-
stream.write(' Aborted — no
|
|
161
|
+
stream.write(' Aborted — no request made.\n');
|
|
203
162
|
return null;
|
|
204
163
|
}
|
|
205
164
|
}
|
|
206
165
|
|
|
207
|
-
const
|
|
208
|
-
const round1MaxTokens = Math.max(MAX_TOKENS, requirements.length * TOKENS_PER_REQUIREMENT);
|
|
209
|
-
|
|
210
|
-
const round1 = await Promise.all(providers.map(async provider => {
|
|
211
|
-
try {
|
|
212
|
-
const raw = await summarizeFn({ mode: 'byok', credentials, provider, prompt: round1Prompt, brief: '', maxTokens: round1MaxTokens });
|
|
213
|
-
return { provider, verdicts: parseVerdicts(requirements, raw), error: null };
|
|
214
|
-
} catch (err) {
|
|
215
|
-
return { provider, verdicts: null, error: err.message };
|
|
216
|
-
}
|
|
217
|
-
}));
|
|
166
|
+
const { diff } = findLinkedCommitsFn(ticketKey, { cwd: process.cwd() });
|
|
218
167
|
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
168
|
+
const runRes = await fetchJson(`${apiBase()}${CONSENSUS_PATH}`, {
|
|
169
|
+
fetcher, timeoutMs: CONSENSUS_TIMEOUT_MS,
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
172
|
+
body: JSON.stringify({ ticketKey, diff, requirements }),
|
|
173
|
+
}).catch(err => ({ ok: false, status: 0, body: null, networkError: err }));
|
|
174
|
+
|
|
175
|
+
if (!runRes.ok) {
|
|
176
|
+
const msg = runRes.body?.error
|
|
177
|
+
?? (runRes.networkError?.name === 'TimeoutError' ? 'Request timed out.' : runRes.networkError?.message)
|
|
178
|
+
?? `HTTP ${runRes.status}`;
|
|
179
|
+
stream.write(` ✖ ${msg}\n`);
|
|
223
180
|
return null;
|
|
224
181
|
}
|
|
225
182
|
|
|
226
|
-
const
|
|
227
|
-
.
|
|
228
|
-
.filter(i => new Set(successful1.map(r => r.verdicts[i])).size > 1);
|
|
229
|
-
|
|
230
|
-
let round2 = [];
|
|
231
|
-
if (disagreedIndexes.length > 0) {
|
|
232
|
-
const disagreedItems = disagreedIndexes.map(i => ({ requirement: requirements[i], index: i }));
|
|
233
|
-
const round2MaxTokens = Math.max(MAX_TOKENS, disagreedItems.length * TOKENS_PER_REQUIREMENT);
|
|
234
|
-
round2 = await Promise.all(successful1.map(async ({ provider }) => {
|
|
235
|
-
const prompt = buildRound2Prompt(diff, disagreedItems, provider, successful1);
|
|
236
|
-
try {
|
|
237
|
-
const raw = await summarizeFn({ mode: 'byok', credentials, provider, prompt, brief: '', maxTokens: round2MaxTokens });
|
|
238
|
-
const refined = parseVerdicts(disagreedItems.map(d => d.requirement), raw);
|
|
239
|
-
return { provider, refined: Object.fromEntries(disagreedItems.map((d, idx) => [d.index, refined[idx]])) };
|
|
240
|
-
} catch (err) {
|
|
241
|
-
stream.write(` ⚠ ${provider}: refinement round failed (${err.message}) — keeping its round-1 verdict.\n`);
|
|
242
|
-
return { provider, refined: {} }; // degrade — keep the round-1 verdict for this agent
|
|
243
|
-
}
|
|
244
|
-
}));
|
|
183
|
+
for (const warning of runRes.body.warnings ?? []) {
|
|
184
|
+
stream.write(` Warning: ${warning}\n`);
|
|
245
185
|
}
|
|
246
186
|
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
return {
|
|
250
|
-
provider: r1.provider,
|
|
251
|
-
round1Verdicts: r1.verdicts,
|
|
252
|
-
verdicts: requirements.map((_, i) => r2?.refined[i] ?? r1.verdicts[i]),
|
|
253
|
-
};
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
const results = requirements.map((requirement, i) => ({
|
|
257
|
-
requirement,
|
|
258
|
-
status: reconcileRequirement(finalVerdictsByAgent.map(a => a.verdicts[i])),
|
|
259
|
-
evidence: null,
|
|
260
|
-
}));
|
|
261
|
-
|
|
262
|
-
const found = results.filter(r => r.status === 'FOUND').length;
|
|
263
|
-
const partial = results.filter(r => r.status === 'PARTIAL').length;
|
|
264
|
-
const coveragePercent = Math.round(((found + partial * 0.5) / results.length) * 100);
|
|
265
|
-
|
|
266
|
-
const report = formatConsensusReport({
|
|
267
|
-
ticketKey, results, coveragePercent, finalVerdictsByAgent, disagreedCount: disagreedIndexes.length, s,
|
|
187
|
+
const { report, coveragePercent } = formatConsensusReport({
|
|
188
|
+
ticketKey, results: runRes.body.results, perAgent: runRes.body.perAgent, disagreedCount: runRes.body.disagreedCount, s,
|
|
268
189
|
});
|
|
269
190
|
|
|
270
|
-
return { report, results, coveragePercent, noCriteria: false };
|
|
191
|
+
return { report, results: runRes.body.results, coveragePercent, noCriteria: false };
|
|
271
192
|
}
|
|
@@ -658,8 +658,8 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
658
658
|
` ${s.cyan('fetch')}, ${s.cyan('doctor')}, and ${s.cyan('standup')} are Free; ${s.cyan('triage')} is Free with some Pro/Team-gated`,
|
|
659
659
|
` options (see ${s.cyan('ticketlens triage --help')}); ${s.cyan('compliance')} and ${s.cyan('pr')} are Free, sharing a`,
|
|
660
660
|
` 3-checks/month cap on their requirements-coverage section, Pro unlimited;`,
|
|
661
|
-
` ${s.cyan('compliance')}'s \`consensus: true\` (Pro)
|
|
662
|
-
`
|
|
661
|
+
` ${s.cyan('compliance')}'s \`consensus: true\` (Pro) runs a multi-agent review server-side against`,
|
|
662
|
+
` a "consensus" role configured at Console > Admin > AI Roles, and always implies`,
|
|
663
663
|
` \`--yes\` under MCP, since there is no TTY for the cost-confirmation prompt;`,
|
|
664
664
|
` ${s.cyan('review')} is Free for branch/files/ticket context — its coverage/focus section`,
|
|
665
665
|
` requires Pro as a plain license check, not a draw on that same counter;`,
|
|
@@ -1106,12 +1106,13 @@ export function printComplianceHelp({ stream = process.stdout } = {}) {
|
|
|
1106
1106
|
'',
|
|
1107
1107
|
` ${s.brand('--profile')}=${s.dim('NAME')} Use a specific Jira profile`,
|
|
1108
1108
|
` ${s.brand('--consensus')} Replace the local matcher with a multi-agent AI review ${s.dim('[Pro]')}`,
|
|
1109
|
-
` ${s.dim(' ')} —
|
|
1110
|
-
` ${s.dim(' ')} AI
|
|
1111
|
-
` ${s.dim(' ')}
|
|
1112
|
-
` ${s.dim(' ')}
|
|
1113
|
-
` ${s.dim(' ')}
|
|
1114
|
-
` ${s.dim(' ')}
|
|
1109
|
+
` ${s.dim(' ')} — requires ${s.cyan('ticketlens login')} and a "consensus" role configured`,
|
|
1110
|
+
` ${s.dim(' ')} at Console > Admin > AI Roles (2+ providers from your team's`,
|
|
1111
|
+
` ${s.dim(' ')} shared pool, Console > Admin > AI Provider Pool). Runs server-side`,
|
|
1112
|
+
` ${s.dim(' ')} — the diff and requirements are sent to TicketLens's backend,`,
|
|
1113
|
+
` ${s.dim(' ')} scanned for secrets first, then to each provider. Reconciles`,
|
|
1114
|
+
` ${s.dim(' ')} disagreements with a refinement round, then a majority vote.`,
|
|
1115
|
+
` ${s.dim(' ')} Prompts for confirmation before spending your team's API credits.`,
|
|
1115
1116
|
` ${s.brand('-y')}, ${s.brand('--yes')} Skip the --consensus cost confirmation prompt`,
|
|
1116
1117
|
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
1117
1118
|
'',
|
|
@@ -42,7 +42,7 @@ export const TOOLS = [
|
|
|
42
42
|
},
|
|
43
43
|
{
|
|
44
44
|
name: 'compliance',
|
|
45
|
-
description: 'Check a ticket\'s acceptance-criteria coverage against the current git diff — extracts requirements from the ticket description, matches them against code changes, and reports a coverage percentage plus what\'s missing. Read-only; the same check `ticketlens install-hooks` runs automatically. Free tier: 3 checks per month; TicketLens Pro removes the limit. Set `consensus: true` (TicketLens Pro) to replace the local matcher with a multi-agent AI review —
|
|
45
|
+
description: 'Check a ticket\'s acceptance-criteria coverage against the current git diff — extracts requirements from the ticket description, matches them against code changes, and reports a coverage percentage plus what\'s missing. Read-only; the same check `ticketlens install-hooks` runs automatically. Free tier: 3 checks per month; TicketLens Pro removes the limit. Set `consensus: true` (TicketLens Pro) to replace the local matcher with a multi-agent AI review, run server-side — requires `ticketlens login` and a "consensus" role configured at Console > Admin > AI Roles (2+ providers attached from the team\'s shared pool, Console > Admin > AI Provider Pool). The diff is scanned for secrets before it is sent. Automatically skips the interactive cost-confirmation prompt since MCP has no TTY.',
|
|
46
46
|
inputSchema: {
|
|
47
47
|
type: 'object',
|
|
48
48
|
properties: {
|
|
@@ -116,7 +116,10 @@ async function callGroq({ brief, apiKey, fetcher, timeoutMs, prompt, maxTokens }
|
|
|
116
116
|
'Authorization': `Bearer ${apiKey}`,
|
|
117
117
|
},
|
|
118
118
|
body: JSON.stringify({
|
|
119
|
-
|
|
119
|
+
// llama-3.1-8b-instant was decommissioned by Groq 2026-08-16 (HTTP 404 on
|
|
120
|
+
// every call since) — openai/gpt-oss-20b is Groq's own recommended
|
|
121
|
+
// replacement. See console.groq.com/docs/deprecations.
|
|
122
|
+
model: 'openai/gpt-oss-20b',
|
|
120
123
|
max_tokens: maxTokens,
|
|
121
124
|
messages: [{ role: 'user', content: prompt + brief }],
|
|
122
125
|
}),
|
|
@@ -577,7 +577,7 @@ export async function runTicketDuplicates(cmdArgs, {
|
|
|
577
577
|
*
|
|
578
578
|
* @param {string[]} cmdArgs - [sourceKey, targetKey]
|
|
579
579
|
* @param {boolean} [cliHints] - see runTicketTransitionList's cliHints doc
|
|
580
|
-
* @returns {Promise<{ ok: boolean, types?: string[] }>}
|
|
580
|
+
* @returns {Promise<{ ok: boolean, types?: (string|{name: string, inward: string, outward: string})[] }>}
|
|
581
581
|
*/
|
|
582
582
|
export async function runTicketLinkList(cmdArgs, {
|
|
583
583
|
configDir = DEFAULT_CONFIG_DIR,
|
|
@@ -607,7 +607,14 @@ export async function runTicketLinkList(cmdArgs, {
|
|
|
607
607
|
return { ok: true, types: [] };
|
|
608
608
|
}
|
|
609
609
|
stream.write(` Available link types for ${s.brand(s.bold(sourceKey))} → ${s.brand(s.bold(targetKey))} (${adapter.type}):\n\n`);
|
|
610
|
-
for (const t of types)
|
|
610
|
+
for (const t of types) {
|
|
611
|
+
// Backlog #31: a bare type name (e.g. "Blocks") hides which end gets
|
|
612
|
+
// which phrase — show the real resulting sentence so the caller
|
|
613
|
+
// doesn't have to guess direction. GitHub/Linear still hand back
|
|
614
|
+
// plain strings (no inward/outward phrase pair to show).
|
|
615
|
+
const line = typeof t === 'string' ? t : `${t.name} — ${sourceKey} ${t.outward ?? t.name} ${targetKey}`;
|
|
616
|
+
stream.write(` ${s.brand('●')} ${line}\n`);
|
|
617
|
+
}
|
|
611
618
|
stream.write('\n');
|
|
612
619
|
if (adapter.type === 'github') {
|
|
613
620
|
stream.write(` Note: GitHub has no generic link relationship — linking will CLOSE ${sourceKey} as a duplicate of ${targetKey}.\n`);
|