ticketlens 0.38.46 → 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 +6 -2
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +14 -4
- package/skills/jtb/scripts/fetch-ticket.mjs +7 -1
- package/skills/jtb/scripts/lib/compliance-checker.mjs +1 -1
- package/skills/jtb/scripts/lib/consensus-checker.mjs +271 -0
- package/skills/jtb/scripts/lib/help.mjs +14 -1
- package/skills/jtb/scripts/lib/mcp-server.mjs +8 -2
- package/skills/jtb/scripts/lib/mcp-tool-schemas.mjs +3 -1
- package/skills/jtb/scripts/lib/note-command.mjs +14 -4
- package/skills/jtb/scripts/lib/recall-vault.mjs +20 -6
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,13 +460,13 @@ 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. `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
|
|
|
463
467
|
**Tags matter for search relevance.** `--tags=a,b` accepts anything, but a generic tag (the project name, "gotcha", "bug") gives future search almost nothing to match on. Tag with what the note is actually *about* — the specific technology, error type, or root cause (`retry-backoff`, `null-pointer`, `auth-middleware`) — so it surfaces when someone else hits the same problem.
|
|
464
468
|
|
|
465
|
-
**Local file attachments.** `note add --attach=path1,path2` (or the `recall_add` MCP tool's `attachments` array) saves a screenshot or file alongside the note, in your local vault (`~/.ticketlens/recall/<PREFIX>/<note-id>/`) — same 10 MB/file, 50 MB/call, 20-file caps as ticket attachments for the local save. With Team Recall sync active, the attachment syncs too — visible and downloadable from Console > Admin > Recall — but the sync path caps at 12 MB/call (the backend's request-size limit, lower than the 50 MB local-save cap). Going over it fails the whole push, not just the attachment — the note stays saved locally, but neither its text nor the attachment reaches the team until it's pushed within the cap. Attachments with plain-text content (detected from the actual bytes, not the filename) go through the same secret scan as the note body before syncing; a rejected scan blocks the whole push the same way.
|
|
469
|
+
**Local file attachments.** `note add --attach=path1,path2` (or the `recall_add` MCP tool's `attachments` array) saves a screenshot or file alongside the note, in your local vault (`~/.ticketlens/recall/<PREFIX>/<note-id>/`) — same 10 MB/file, 50 MB/call, 20-file caps as ticket attachments for the local save. `note patch --attach=path1,path2` (or `recall_update`'s `attachments` array) attaches more files to an existing note later — appended to what's already there, never replacing it; patch is local-vault only, so these are never pushed even if the note's original attachments were. With Team Recall sync active, the attachment syncs too — visible and downloadable from Console > Admin > Recall — but the sync path caps at 12 MB/call (the backend's request-size limit, lower than the 50 MB local-save cap). Going over it fails the whole push, not just the attachment — the note stays saved locally, but neither its text nor the attachment reaches the team until it's pushed within the cap. Attachments with plain-text content (detected from the actual bytes, not the filename) go through the same secret scan as the note body before syncing; a rejected scan blocks the whole push the same way.
|
|
466
470
|
|
|
467
471
|
**Gaps** — every `ticketlens PROJ-123` brief also diffs the ticket's own description against its linked tickets (from the depth traversal you already requested) and its own downloaded attachments, looking for requirements mentioned there but missing here. Anything uncovered shows up under a `## Gaps` section, citing exactly where it came from — a linked ticket key or an attachment filename — as evidence, never an instruction to act on. Nothing is saved anywhere; it's recomputed fresh on every fetch. Requires a Pro license, same as Recall. No network call beyond what the brief already made.
|
|
468
472
|
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.42.
|
|
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.
|
|
@@ -397,7 +397,9 @@ When it does apply, run up to 3 rounds:
|
|
|
397
397
|
```
|
|
398
398
|
`--expect-mtime` is what keeps this safe: if the file changed since step 1 (the user hand-edited it while you were drafting), the patch silently no-ops and prints "not found or already changed" — the user's own edit always wins, never gets clobbered by a stale background draft.
|
|
399
399
|
|
|
400
|
-
|
|
400
|
+
`note patch` also takes `--attach=path1,path2` (Pro), same caps and secret-scan treatment as `note add`'s — new files are appended to whatever the note already has, never replacing existing attachments.
|
|
401
|
+
|
|
402
|
+
If this harness has TicketLens's MCP server configured (a tool named `recall_update` — often shown as `mcp__ticketlens__recall_update` — visible in your tool list), prefer it over the bash form: same license gate, same structural/secret-scan checks, same optimistic-concurrency behavior via `expectMtime` — just no shell command or stdin piping to construct. It accepts `id`/`body`/`ticket`/`expectMtime`/`attachments`.
|
|
401
403
|
5. Repeat from step 1 (re-capture mtime/body fresh each round) up to 3 total rounds. If no round ever produces a fully-passing draft, patch in whichever round scored highest across all attempts, and let the "not found or already changed" message stand if that patch itself loses a late race — don't retry past round 3.
|
|
402
404
|
|
|
403
405
|
This never calls any external API or bills any tokens beyond the session you already have open — the generator and validator are subagents inside your own Claude Code session, not a TicketLens server call.
|
|
@@ -527,12 +529,20 @@ Use this evaluation order:
|
|
|
527
529
|
3. **Manual checklist** — list each requirement for the developer to verify manually
|
|
528
530
|
|
|
529
531
|
### Privacy
|
|
530
|
-
`--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.)
|
|
531
533
|
|
|
532
534
|
### Standalone command
|
|
533
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.
|
|
534
536
|
|
|
535
|
-
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
|
|
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.
|
|
536
546
|
|
|
537
547
|
### Ledger export
|
|
538
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
|
|
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) {
|
|
@@ -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
|
|
|
@@ -393,11 +396,14 @@ async function callRecallAdd(args, { configDir, runNoteAddFn }) {
|
|
|
393
396
|
* as buildNoteAddArgs above. `expectMtime` is optimistic-concurrency: a
|
|
394
397
|
* caller that fetched a note via recall_search and wants to refine it
|
|
395
398
|
* without racing a concurrent edit passes back the mtime it observed.
|
|
399
|
+
* `attachments` (backlog #21) is appended to whatever the note already has,
|
|
400
|
+
* same as buildNoteAddArgs's — never a replace.
|
|
396
401
|
*/
|
|
397
|
-
function buildNotePatchArgs({ id, ticket, expectMtime }) {
|
|
402
|
+
function buildNotePatchArgs({ id, ticket, expectMtime, attachments }) {
|
|
398
403
|
const args = [`--id=${id}`];
|
|
399
404
|
if (ticket) args.push(`--ticket=${ticket}`);
|
|
400
405
|
if (expectMtime !== undefined) args.push(`--expect-mtime=${expectMtime}`);
|
|
406
|
+
if (Array.isArray(attachments) && attachments.length > 0) args.push(`--attach=${attachments.join(',')}`);
|
|
401
407
|
return args;
|
|
402
408
|
}
|
|
403
409
|
|
|
@@ -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
|
},
|
|
@@ -180,6 +181,7 @@ export const TOOLS = [
|
|
|
180
181
|
body: { type: 'string', description: 'The replacement note body — brief, not a paragraph. ~20 (strict), 30 (balanced), or 50 (loose) words max — scales with recallStrictness — going over doesn\'t block the update, but prints a non-blocking warning.' },
|
|
181
182
|
ticket: { type: 'string', description: 'Ticket key the note is about, e.g. PROJ-123. Optional — narrows the search when omitted, the vault is searched across all ticket prefixes.' },
|
|
182
183
|
expectMtime: { type: 'number', description: 'The note file\'s mtime (ms) last observed by the caller. If the note changed since then, the patch is a no-op rather than an overwrite — optimistic concurrency, not a hard requirement.' },
|
|
184
|
+
attachments: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach (screenshots, logs, etc.) — appended to whatever the note already has, never replacing existing attachments. Same 10MB/file, 50MB/call, 20-file caps as recall_add. Local vault only — recall_update never syncs to a team backend, so this never pushes.' },
|
|
183
185
|
},
|
|
184
186
|
required: ['id', 'body'],
|
|
185
187
|
},
|
|
@@ -228,7 +228,9 @@ export async function runNoteAdd(cmdArgs, {
|
|
|
228
228
|
* meant to be typed by hand): the loop's generator subagent produces a body,
|
|
229
229
|
* SKILL.md's orchestration pipes it through this command, and the new body
|
|
230
230
|
* gets exactly the same structural and secret gates a user-typed body gets —
|
|
231
|
-
* there is no weaker path here for AI-authored content.
|
|
231
|
+
* there is no weaker path here for AI-authored content. `--attach` works the
|
|
232
|
+
* same as `note add`'s — files are appended to whatever the note already has,
|
|
233
|
+
* never replacing them.
|
|
232
234
|
*
|
|
233
235
|
* @param {string[]} cmdArgs
|
|
234
236
|
* @returns {Promise<{ patched: boolean }>}
|
|
@@ -245,6 +247,7 @@ export async function runNotePatch(cmdArgs, {
|
|
|
245
247
|
resolveEffectiveRecallStrictnessFn = resolveEffectiveRecallStrictness,
|
|
246
248
|
scanForSecretsFn = scanForSecrets,
|
|
247
249
|
patchNoteBodyFn = patchNoteBody,
|
|
250
|
+
readAttachmentsFn = readAttachments,
|
|
248
251
|
} = {}) {
|
|
249
252
|
if (!isLicensedFn('pro', configDir)) {
|
|
250
253
|
showUpgradePrompt('pro', 'ticketlens note', { stream });
|
|
@@ -253,7 +256,7 @@ export async function runNotePatch(cmdArgs, {
|
|
|
253
256
|
|
|
254
257
|
const id = parseFlag(cmdArgs, 'id');
|
|
255
258
|
if (!id) {
|
|
256
|
-
stream.write('Usage: ticketlens note patch --id="..." [--ticket=KEY]\n');
|
|
259
|
+
stream.write('Usage: ticketlens note patch --id="..." [--ticket=KEY] [--attach=path1,path2]\n');
|
|
257
260
|
return { patched: false };
|
|
258
261
|
}
|
|
259
262
|
|
|
@@ -288,11 +291,18 @@ export async function runNotePatch(cmdArgs, {
|
|
|
288
291
|
stream.write(` Warning: ${warning}\n`);
|
|
289
292
|
}
|
|
290
293
|
|
|
294
|
+
const attachPaths = parseAttachPaths(cmdArgs);
|
|
295
|
+
const { saved: attachments, warnings: attachWarnings } = attachPaths.length > 0
|
|
296
|
+
? summarizeAttachments(readAttachmentsFn(attachPaths))
|
|
297
|
+
: { saved: [], warnings: [] };
|
|
298
|
+
for (const warning of attachWarnings) stream.write(warning);
|
|
299
|
+
|
|
291
300
|
const ticketKeys = ticketKey ? [ticketKey] : [];
|
|
292
301
|
const expectMtimeArg = parseFlag(cmdArgs, 'expect-mtime');
|
|
293
302
|
const expectedMtimeMs = expectMtimeArg !== undefined ? Number(expectMtimeArg) : undefined;
|
|
294
|
-
const { patched } = patchNoteBodyFn({ id, ticketKeys, body, expectedMtimeMs }, { configDir });
|
|
295
|
-
|
|
303
|
+
const { patched } = patchNoteBodyFn({ id, ticketKeys, body, expectedMtimeMs, attachments }, { configDir });
|
|
304
|
+
const attachSuffix = patched && attachments.length > 0 ? ` + ${attachments.length} attachment(s)` : '';
|
|
305
|
+
stream.write(patched ? ` Updated note (${id})${attachSuffix}\n` : ` Note not updated — (${id}) not found or already changed.\n`);
|
|
296
306
|
return { patched };
|
|
297
307
|
}
|
|
298
308
|
|
|
@@ -90,7 +90,10 @@ function writeAttachments(noteDir, id, attachments) {
|
|
|
90
90
|
if (attachments.length === 0) return [];
|
|
91
91
|
const attachDir = attachmentsDirFor(noteDir, id);
|
|
92
92
|
fs.mkdirSync(attachDir, { recursive: true });
|
|
93
|
-
|
|
93
|
+
// Seeded from what's already on disk, not just this call's batch — patchNoteBody
|
|
94
|
+
// can call this a second time against the same note id, and a same-named file
|
|
95
|
+
// from an earlier add/patch must count toward uniqueness too.
|
|
96
|
+
const used = new Set(fs.readdirSync(attachDir));
|
|
94
97
|
const saved = [];
|
|
95
98
|
for (const { filename, buffer } of attachments) {
|
|
96
99
|
const uniqueName = uniquifyFilename(filename, used);
|
|
@@ -248,8 +251,10 @@ export function deleteNoteAnyPrefix(externalId, { configDir = DEFAULT_CONFIG_DIR
|
|
|
248
251
|
* Overwrites an existing local note's body in place — used by the jtb skill's
|
|
249
252
|
* generator/validator quality loop to swap in a better draft after `note add`
|
|
250
253
|
* already saved the original. Patch-only: never creates a note, and every
|
|
251
|
-
* frontmatter field except body is carried over unchanged, so
|
|
252
|
-
* become a covert way to retitle/retag/re-tie a note to a
|
|
254
|
+
* frontmatter field except body and attachments is carried over unchanged, so
|
|
255
|
+
* this can never become a covert way to retitle/retag/re-tie a note to a
|
|
256
|
+
* different ticket. New attachments are appended to whatever the note already
|
|
257
|
+
* has — a patch refines a note, it never discards what's already attached.
|
|
253
258
|
*
|
|
254
259
|
* Guards, same failure-mode split as deleteNote: a malformed id or ticket key
|
|
255
260
|
* is a caller bug (throw); a missing file, an externalId that doesn't match
|
|
@@ -257,17 +262,18 @@ export function deleteNoteAnyPrefix(externalId, { configDir = DEFAULT_CONFIG_DIR
|
|
|
257
262
|
* (expectedMtimeMs) are all best-effort no-ops, not errors — the original
|
|
258
263
|
* note is always left exactly as-is on any of these.
|
|
259
264
|
*
|
|
260
|
-
* @param {{ id: string, ticketKeys?: string[], body: string, expectedMtimeMs?: number }} params
|
|
265
|
+
* @param {{ id: string, ticketKeys?: string[], body: string, expectedMtimeMs?: number, attachments?: Array<{ filename: string, buffer: Buffer }> }} params
|
|
261
266
|
* @param {{ configDir?: string }} [opts]
|
|
262
267
|
* @returns {{ patched: boolean, path: string|null }}
|
|
263
268
|
*/
|
|
264
|
-
export function patchNoteBody({ id, ticketKeys = [], body, expectedMtimeMs }, { configDir = DEFAULT_CONFIG_DIR } = {}) {
|
|
269
|
+
export function patchNoteBody({ id, ticketKeys = [], body, expectedMtimeMs, attachments = [] }, { configDir = DEFAULT_CONFIG_DIR } = {}) {
|
|
265
270
|
if (!EXTERNAL_ID_PATTERN.test(id)) {
|
|
266
271
|
throw new Error(`Invalid note id: "${id}"`);
|
|
267
272
|
}
|
|
268
273
|
|
|
269
274
|
const prefix = resolvePrefix(ticketKeys[0]);
|
|
270
|
-
const
|
|
275
|
+
const dir = prefixDir(configDir, prefix);
|
|
276
|
+
const notePath = path.join(dir, id);
|
|
271
277
|
|
|
272
278
|
if (!fs.existsSync(notePath)) {
|
|
273
279
|
return { patched: false, path: null };
|
|
@@ -281,6 +287,13 @@ export function patchNoteBody({ id, ticketKeys = [], body, expectedMtimeMs }, {
|
|
|
281
287
|
return { patched: false, path: notePath };
|
|
282
288
|
}
|
|
283
289
|
|
|
290
|
+
// existing.attachments is the full list already on disk (from add and/or a
|
|
291
|
+
// prior patch) — carried forward here, or the drop bug returns: patch would
|
|
292
|
+
// silently strip the attachments: key, orphaning files that stay on disk but
|
|
293
|
+
// become permanently unlisted and unpushable.
|
|
294
|
+
const savedAttachments = writeAttachments(dir, id, attachments);
|
|
295
|
+
const mergedAttachments = [...existing.attachments, ...savedAttachments];
|
|
296
|
+
|
|
284
297
|
const data = {
|
|
285
298
|
title: existing.title,
|
|
286
299
|
aliases: existing.aliases,
|
|
@@ -291,6 +304,7 @@ export function patchNoteBody({ id, ticketKeys = [], body, expectedMtimeMs }, {
|
|
|
291
304
|
status: existing.status,
|
|
292
305
|
sources: existing.sources,
|
|
293
306
|
externalId: existing.externalId,
|
|
307
|
+
...(mergedAttachments.length > 0 ? { attachments: mergedAttachments } : {}),
|
|
294
308
|
};
|
|
295
309
|
|
|
296
310
|
writeFileAtomically(notePath, serializeFrontmatter(data, body));
|