ticketlens 0.38.48 → 0.38.49

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 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 every AI provider configured in `~/.ticketlens/credentials.json` (2+ of `anthropicApiKey`/`openaiApiKey`/`groqApiKey` required), reconciled by majority vote after a disagreement-triggered refinement round. This is the only compliance path that sends your diff off-machine — directly to each configured AI provider, never to TicketLens's own servers. The diff is scanned for secrets before anything is sent; a detected secret blocks the run with no network call made. Prompts for confirmation before spending API credits unless `-y`/`--yes` is passed.
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 — sends the diff directly to each of your configured AI providers (never to TicketLens's own servers), requires 2+ of anthropic/openai/groq keys configured, 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).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.38.48",
3
+ "version": "0.38.49",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.42.7 -->
1
+ <!-- jtb-skill-version: 0.43.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.
@@ -539,10 +539,11 @@ If this harness has TicketLens's MCP server configured (a tool named `compliance
539
539
  ### --consensus: Multi-Agent AI Review (standalone command only, Pro)
540
540
  `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
541
 
542
- - **This is the one path in TicketLens that sends your diff off-machine** — directly to each AI provider you've configured (never to TicketLens's own servers). Requires **2+** of `anthropicApiKey`/`openaiApiKey`/`groqApiKey` set in `~/.ticketlens/credentials.json` (the same local BYOK file `ticketlens summarize` uses — set up via `ticketlens init` or manually). Before anything is sent, the diff runs through the same secret scanner Recall notes use (`secret-scanner.mjs`) — a detected secret blocks the run entirely, before any network call.
543
- - Each configured 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.
544
- - Prompts for confirmation before making any API calls (real cost against your own provider keys) unless `-y`/`--yes` is passed. Over MCP, `consensus: true` always implies yes (no TTY to prompt).
545
- - `Phase 2` (not yet built): GLM/Kimi/DeepSeek/Qwen/Devstral/Gemini/Gemma providers. `Phase 1.5` (not yet built): scored iterative refinement with a stronger-model arbiter judging consensus acceptability, instead of plain majority vote.
542
+ - **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.
543
+ - 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.
544
+ - 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).
545
+ - 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.
546
+ - `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
547
 
547
548
  ### Ledger export
548
549
  `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`).
@@ -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 { loadCredentials } from './profile-resolver.mjs';
5
- import { summarize } from './summarizer.mjs';
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 PROVIDER_ORDER = ['anthropic', 'openai', 'groq'];
12
- const PROVIDER_KEY_FIELD = { anthropic: 'anthropicApiKey', openai: 'openaiApiKey', groq: 'groqApiKey' };
13
- // Lower = stricter (less coverage claimed). Drives tie-break in reconcileRequirement.
14
- const STRICTNESS = { NOT_FOUND: 0, PARTIAL: 1, FOUND: 2 };
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 make ${providerCount} AI API call(s) using your configured keys. Continue? y/N `);
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, coveragePercent, finalVerdictsByAgent, disagreedCount, s }) {
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(`${finalVerdictsByAgent.length} agents: ${finalVerdictsByAgent.map(a => a.provider).join(', ')}`)}`,
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 { provider, verdicts, round1Verdicts } of finalVerdictsByAgent) {
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(provider)}: ${parts.join(', ')}`);
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
- scanForSecretsFn = scanForSecrets,
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 { diff } = findLinkedCommitsFn(ticketKey, { cwd: process.cwd() });
175
-
176
- // --consensus is the only compliance path that sends the diff off-machine (to each
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
- for (const warning of scan.warnings) {
185
- stream.write(` Warning: ${warning}\n`);
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 credentials = loadCredentialsFn(configDir);
189
- const providers = getConfiguredProviders(credentials);
190
- if (providers.length < 2) {
191
- stream.write(
192
- ' ✖ --consensus needs at least 2 configured AI providers. Configure with: ' +
193
- 'ticketlens cloud-keys add <provider> <key>, or add anthropicApiKey/openaiApiKey/groqApiKey ' +
194
- 'to ~/.ticketlens/credentials.json.\n'
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 API calls made.\n');
161
+ stream.write(' Aborted — no request made.\n');
203
162
  return null;
204
163
  }
205
164
  }
206
165
 
207
- const round1Prompt = buildRound1Prompt(diff, requirements);
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 successful1 = round1.filter(r => !r.error);
220
- if (successful1.length < 2) {
221
- stream.write(` ✖ Only ${successful1.length} provider(s) responded successfully — need at least 2 for consensus.\n`);
222
- for (const r of round1) if (r.error) stream.write(` ${r.provider}: ${r.error}\n`);
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 disagreedIndexes = requirements
227
- .map((_, i) => i)
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 finalVerdictsByAgent = successful1.map(r1 => {
248
- const r2 = round2.find(r => r.provider === r1.provider);
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) sends the diff directly to your configured`,
662
- ` AI providers instead — never to TicketLens's own servers — and always implies`,
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(' ')} — routes the same requirements-vs-diff check through every`,
1110
- ` ${s.dim(' ')} AI provider configured in ~/.ticketlens/credentials.json`,
1111
- ` ${s.dim(' ')} (2+ of anthropicApiKey/openaiApiKey/groqApiKey required),`,
1112
- ` ${s.dim(' ')} reconciles disagreements with a refinement round, then a`,
1113
- ` ${s.dim(' ')} majority vote. Calls each provider directly — never TicketLens's`,
1114
- ` ${s.dim(' ')} own servers. Prompts for confirmation before spending API credits.`,
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 — routes the same check through every AI provider configured in ~/.ticketlens/credentials.json (2+ required), calling each directly (never TicketLens\'s own servers). Requires 2+ of anthropic/openai/groq keys configured via `cloud-keys` or credentials.json; automatically skips the interactive cost-confirmation prompt since MCP has no TTY.',
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
- model: 'llama-3.1-8b-instant',
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
  }),