ticketlens 0.38.47 → 0.38.48

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
@@ -294,10 +294,14 @@ Flag validation provides actionable hints:
294
294
  ticketlens compliance <TICKET-KEY> # Check ticket requirements against local diff [Pro/Free 3/mo]
295
295
  ticketlens compliance <TICKET-KEY> --profile=acme # Specify a profile
296
296
  ticketlens compliance <TICKET-KEY> --plain # Plain markdown output
297
+ ticketlens compliance <TICKET-KEY> --consensus # Multi-agent AI review instead of the local matcher [Pro]
298
+ ticketlens compliance <TICKET-KEY> --consensus -y # Same, skipping the cost-confirmation prompt
297
299
  ```
298
300
 
299
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.
300
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.
304
+
301
305
  ---
302
306
 
303
307
  ### Compliance Ledger
@@ -456,7 +460,7 @@ Every note is scanned before saving — anything shaped like a real secret (API
456
460
 
457
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).
458
462
 
459
- **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; `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 — 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).
460
464
 
461
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.
462
466
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.38.47",
3
+ "version": "0.38.48",
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.6 -->
1
+ <!-- jtb-skill-version: 0.42.7 -->
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.
@@ -529,12 +529,20 @@ Use this evaluation order:
529
529
  3. **Manual checklist** — list each requirement for the developer to verify manually
530
530
 
531
531
  ### Privacy
532
- `--compliance` never sends data anywhere. The diff stays local. All analysis is performed by Claude Code within your session context.
532
+ `--compliance` never sends data anywhere. The diff stays local. All analysis is performed by Claude Code within your session context. (The standalone command's `--consensus` opt-in below is the one exception — see that section.)
533
533
 
534
534
  ### Standalone command
535
535
  The same tier-gated check also runs as its own command — `ticketlens compliance PROJ-123` — independent of a full ticket fetch. This is what `ticketlens install-hooks` wires into a pre-push git hook (`ticketlens compliance "$KEY" || exit 1`, gated on a configurable coverage threshold). It shares the same `FREE_LIMIT`/Pro gate and the same compliance ledger as the `--compliance` flag above.
536
536
 
537
- If this harness has TicketLens's MCP server configured (a tool named `compliance` — often shown as `mcp__ticketlens__compliance` — visible in your tool list), prefer it over the bash form: same tier gate (Free: 3 checks/month, Pro: unlimited), same report — just no shell command to construct or stdout to parse. It accepts `ticket`/`profile`, matching the standalone command's arguments above.
537
+ If this harness has TicketLens's MCP server configured (a tool named `compliance` — often shown as `mcp__ticketlens__compliance` — visible in your tool list), prefer it over the bash form: same tier gate (Free: 3 checks/month, Pro: unlimited), same report — just no shell command to construct or stdout to parse. It accepts `ticket`/`profile`/`consensus`, matching the standalone command's arguments below.
538
+
539
+ ### --consensus: Multi-Agent AI Review (standalone command only, Pro)
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
+
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.
538
546
 
539
547
  ### Ledger export
540
548
  `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`).
@@ -33,6 +33,7 @@ import { apiBase } from './lib/api-utils.mjs';
33
33
  import { isLicensed, showUpgradePrompt, readLicense } from './lib/license.mjs';
34
34
  import { detectVcs } from './lib/vcs-detector.mjs';
35
35
  import { runComplianceCheck } from './lib/compliance-checker.mjs';
36
+ import { runConsensusCheck } from './lib/consensus-checker.mjs';
36
37
  import { fetchRemoteLinks, buildAuthHeader } from './lib/jira-client.mjs';
37
38
  import { fetchConfluencePage } from './lib/confluence-client.mjs';
38
39
 
@@ -683,13 +684,18 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
683
684
  const codeRefsC = extractCodeReferences(allTextC);
684
685
  const briefC = assembleBrief(ticketC, codeRefsC);
685
686
 
686
- const complianceRunner = opts.runComplianceCheck ?? runComplianceCheck;
687
+ const useConsensus = args.includes('--consensus');
688
+ const forceYes = args.includes('--yes') || args.includes('-y');
689
+ const complianceRunner = useConsensus
690
+ ? (opts.runConsensusCheck ?? runConsensusCheck)
691
+ : (opts.runComplianceCheck ?? runComplianceCheck);
687
692
  const complianceResult = await complianceRunner({
688
693
  brief: briefC,
689
694
  description: ticketC.description,
690
695
  ticketKey: ticketKeyArg,
691
696
  configDir: resolvedConfigDir,
692
697
  stream: errStream,
698
+ ...(useConsensus ? { forceYes } : {}),
693
699
  });
694
700
 
695
701
  if (complianceResult === null) {
@@ -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 = 'http://api.ticketlens.test';
6
+ export const DEFAULT_API_BASE = 'https://api.ticketlens.app';
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,
@@ -19,7 +19,7 @@ export function statusColor(status, s) {
19
19
  // Shared with matchColor in ticket-command.mjs (duplicates' match-confidence
20
20
  // tiers) — same 70/50 thresholds, same green/yellow/dim vocabulary, applied
21
21
  // here to overall requirement coverage instead of a single match score.
22
- function coverageColor(pct, s) {
22
+ export function coverageColor(pct, s) {
23
23
  if (pct >= 70) return s.green;
24
24
  if (pct >= 50) return s.yellow;
25
25
  return s.dim;
@@ -0,0 +1,271 @@
1
+ import { isLicensed, showUpgradePrompt } from './license.mjs';
2
+ import { extractRequirements } from './requirement-extractor.mjs';
3
+ import { findLinkedCommits } from './commit-linker.mjs';
4
+ import { loadCredentials } from './profile-resolver.mjs';
5
+ import { summarize } from './summarizer.mjs';
6
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
7
+ import { createStyler } from './ansi.mjs';
8
+ import { STATUS_ICON, statusColor, coverageColor } from './compliance-checker.mjs';
9
+ import { scanForSecrets } from './secret-scanner.mjs';
10
+
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
+ }
80
+
81
+ /** Interactive y/N cost-confirmation gate — same non-interactive fallback shape as confirmDestructive. */
82
+ async function confirmCost(providerCount, { stream = process.stderr, stdin = process.stdin } = {}) {
83
+ if (!stdin.isTTY || !stdin.setRawMode) {
84
+ stream.write(' Non-interactive mode: pass --yes/-y to run --consensus without a prompt.\n');
85
+ return false;
86
+ }
87
+ stream.write(` --consensus will make ${providerCount} AI API call(s) using your configured keys. Continue? y/N `);
88
+ return new Promise(resolve => {
89
+ stdin.setRawMode(true);
90
+ stdin.resume();
91
+ stdin.once('data', buf => {
92
+ stdin.setRawMode(false);
93
+ stdin.pause();
94
+ const confirmed = buf.toString().toLowerCase() === 'y';
95
+ stream.write(confirmed ? 'y\n' : 'N\n');
96
+ resolve(confirmed);
97
+ });
98
+ });
99
+ }
100
+
101
+ function formatNoCriteriaReport(ticketKey, s) {
102
+ return [
103
+ '',
104
+ ` Consensus Compliance Check — ${s.brand(s.bold(ticketKey))}`,
105
+ ` ${s.dim('─'.repeat(50))}`,
106
+ '',
107
+ ' No acceptance criteria found in ticket description.',
108
+ ' Add a "Acceptance Criteria" section or Given/When/Then statements.',
109
+ '',
110
+ ].join('\n');
111
+ }
112
+
113
+ function formatConsensusReport({ ticketKey, results, coveragePercent, finalVerdictsByAgent, disagreedCount, s }) {
114
+ const lines = [
115
+ '',
116
+ ` Consensus Compliance Check — ${s.brand(s.bold(ticketKey))}`,
117
+ ` ${s.dim('─'.repeat(50))}`,
118
+ ` ${s.dim(`${finalVerdictsByAgent.length} agents: ${finalVerdictsByAgent.map(a => a.provider).join(', ')}`)}`,
119
+ '',
120
+ ];
121
+
122
+ for (const { requirement, status } of results) {
123
+ const icon = statusColor(status, s)(STATUS_ICON[status] ?? '?');
124
+ lines.push(` ${icon} ${requirement}`);
125
+ }
126
+
127
+ lines.push('');
128
+ const found = results.filter(r => r.status === 'FOUND').length;
129
+ lines.push(` Coverage: ${coverageColor(coveragePercent, s)(`${coveragePercent}%`)} (${found}/${results.length} requirements found)`);
130
+ if (disagreedCount > 0) {
131
+ lines.push(` ${s.dim(`${disagreedCount} requirement(s) needed a refinement round (agents initially disagreed).`)}`);
132
+ }
133
+ lines.push('');
134
+ lines.push(` ${s.bold('Per-agent breakdown:')}`);
135
+ for (const { provider, verdicts, round1Verdicts } of finalVerdictsByAgent) {
136
+ const parts = verdicts.map((v, i) => (round1Verdicts[i] !== v ? `${round1Verdicts[i]}→${v}` : v));
137
+ lines.push(` ${s.dim(provider)}: ${parts.join(', ')}`);
138
+ }
139
+ lines.push('');
140
+
141
+ return lines.join('\n');
142
+ }
143
+
144
+ export async function runConsensusCheck({
145
+ brief,
146
+ description = null,
147
+ ticketKey,
148
+ configDir = DEFAULT_CONFIG_DIR,
149
+ stream = process.stderr,
150
+ outStream = process.stdout,
151
+ forceYes = false,
152
+ stdin = process.stdin,
153
+ isLicensedFn = isLicensed,
154
+ showUpgradeFn = showUpgradePrompt,
155
+ extractRequirementsFn = extractRequirements,
156
+ findLinkedCommitsFn = findLinkedCommits,
157
+ loadCredentialsFn = loadCredentials,
158
+ summarizeFn = summarize,
159
+ confirmCostFn = confirmCost,
160
+ scanForSecretsFn = scanForSecrets,
161
+ }) {
162
+ if (!isLicensedFn('pro', configDir)) {
163
+ showUpgradeFn('pro', '--consensus', { stream });
164
+ return null;
165
+ }
166
+
167
+ const requirements = extractRequirementsFn(description ?? brief);
168
+ const s = createStyler({ isTTY: outStream.isTTY });
169
+
170
+ if (requirements.length === 0) {
171
+ return { report: formatNoCriteriaReport(ticketKey, s), results: [], coveragePercent: 0, noCriteria: true };
172
+ }
173
+
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`);
182
+ return null;
183
+ }
184
+ for (const warning of scan.warnings) {
185
+ stream.write(` Warning: ${warning}\n`);
186
+ }
187
+
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
+ );
196
+ return null;
197
+ }
198
+
199
+ if (!forceYes) {
200
+ const proceed = await confirmCostFn(providers.length, { stream, stdin });
201
+ if (!proceed) {
202
+ stream.write(' Aborted — no API calls made.\n');
203
+ return null;
204
+ }
205
+ }
206
+
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
+ }));
218
+
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`);
223
+ return null;
224
+ }
225
+
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
+ }));
245
+ }
246
+
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,
268
+ });
269
+
270
+ return { report, results, coveragePercent, noCriteria: false };
271
+ }
@@ -658,6 +658,9 @@ 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`,
663
+ ` \`--yes\` under MCP, since there is no TTY for the cost-confirmation prompt;`,
661
664
  ` ${s.cyan('review')} is Free for branch/files/ticket context — its coverage/focus section`,
662
665
  ` requires Pro as a plain license check, not a draw on that same counter;`,
663
666
  ` ${s.cyan('stats')} is Free with a 7-day lookback, Pro extends it to 30 days, same split`,
@@ -1089,7 +1092,7 @@ export function printComplianceHelp({ stream = process.stdout } = {}) {
1089
1092
  const s = createStyler({ isTTY: stream.isTTY });
1090
1093
  const lines = [
1091
1094
  '',
1092
- ` ${s.bold(s.brand('ticketlens'))} ${s.bold('compliance')} ${s.dim('<TICKET-KEY> [--profile=NAME]')} ${s.dim('[Pro/Free 3/mo]')}`,
1095
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('compliance')} ${s.dim('<TICKET-KEY> [--profile=NAME] [--consensus [-y]]')} ${s.dim('[Pro/Free 3/mo]')}`,
1093
1096
  '',
1094
1097
  ` Check your current branch's diff against the ticket's requirements.`,
1095
1098
  ` Extracts candidate requirements from the ticket description and diffs`,
@@ -1102,12 +1105,22 @@ export function printComplianceHelp({ stream = process.stdout } = {}) {
1102
1105
  ` ${s.bold('OPTIONS')}`,
1103
1106
  '',
1104
1107
  ` ${s.brand('--profile')}=${s.dim('NAME')} Use a specific Jira profile`,
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.`,
1115
+ ` ${s.brand('-y')}, ${s.brand('--yes')} Skip the --consensus cost confirmation prompt`,
1105
1116
  ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
1106
1117
  '',
1107
1118
  ` ${s.bold('EXAMPLES')}`,
1108
1119
  '',
1109
1120
  ` ${s.dim('$')} ticketlens compliance PROJ-123`,
1110
1121
  ` ${s.dim('$')} ticketlens compliance PROJ-123 --profile=myteam`,
1122
+ ` ${s.dim('$')} ticketlens compliance PROJ-123 --consensus`,
1123
+ ` ${s.dim('$')} ticketlens compliance PROJ-123 --consensus -y`,
1111
1124
  '',
1112
1125
  ];
1113
1126
  stream.write(lines.join('\n') + '\n');
@@ -174,9 +174,12 @@ async function callTriage(args, { configDir, runTriageFn }) {
174
174
  * printErr through it before this tool existed — see the fetch tool's own
175
175
  * shipping notes for why that treatment was deferred per-tool).
176
176
  */
177
- function buildComplianceArgs({ ticket, profile }) {
177
+ function buildComplianceArgs({ ticket, profile, consensus }) {
178
178
  const args = ['compliance', ticket];
179
179
  if (profile) args.push(`--profile=${profile}`);
180
+ // MCP has no TTY to answer the interactive cost-confirmation prompt, so --consensus
181
+ // always implies --yes here — the caller already opted in by setting consensus: true.
182
+ if (consensus) args.push('--consensus', '--yes');
180
183
  return args;
181
184
  }
182
185
 
@@ -42,12 +42,13 @@ 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.',
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.',
46
46
  inputSchema: {
47
47
  type: 'object',
48
48
  properties: {
49
49
  ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
50
50
  profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
51
+ consensus: { type: 'boolean', description: 'Use multi-agent AI consensus instead of the local deterministic matcher. Requires TicketLens Pro and 2+ configured AI provider keys.' },
51
52
  },
52
53
  required: ['ticket'],
53
54
  },