ticketlens 0.21.9 → 0.21.11

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
@@ -260,8 +260,8 @@ Runs the same compliance check as `ticketlens CNV1-2 --compliance` but as a dedi
260
260
  ### Compliance Ledger
261
261
 
262
262
  ```bash
263
- ticketlens ledger # View the local compliance audit ledger [Pro]
264
- ticketlens ledger --plain # Plain markdown output
263
+ ticketlens ledger # View the local compliance audit ledger, JSON + signature [Pro]
264
+ ticketlens ledger --format=csv # Flat CSV export, no signature [Pro]
265
265
  ```
266
266
 
267
267
  Displays the append-only local ledger of all compliance checks run on this machine. Useful for SOC 2 / HIPAA audit trails. Requires a Pro license.
@@ -365,9 +365,11 @@ Cache locations:
365
365
  ticketlens schedule # Interactive wizard — set digest time, timezone, profile [Pro]
366
366
  ticketlens schedule --stop # Cancel the scheduled digest
367
367
  ticketlens schedule --status # Show current schedule
368
- ticketlens schedule --local # Local-only cron (no Console auth) — saves triage to file [Pro]
368
+ ticketlens schedule --local --time=07:00 --save=./triage.txt # Local-only cron/LaunchAgent, no Console auth [Pro]
369
369
  ```
370
370
 
371
+ Not logged in? `ticketlens schedule` falls back to local-only mode automatically — same as passing `--local` explicitly.
372
+
371
373
  Stores the schedule as a cron entry. Delivers your triage digest at the configured time without an open terminal. Requires a Pro license.
372
374
 
373
375
  ---
@@ -401,7 +403,7 @@ Every note is scanned before saving — anything shaped like a real secret (API
401
403
 
402
404
  **Quality loop:** inside a Claude Code session using the jtb skill, a saved note can be silently refined afterward — a generator subagent drafts a more actionable version, a validator subagent checks it against other notes on the same ticket for duplication, up to 3 rounds — and the improved draft overwrites the original via the internal `note patch` command (not typically invoked by hand). This makes zero API calls and costs zero extra tokens beyond your already-running session; it never runs for a bare shell invocation of `note add`, which is skipped silently. Known limitation: a refined draft is not re-synced to your team even if the original was — teammates who already pulled the note keep the earlier draft.
403
405
 
404
- **Team sync:** on a Team plan with Recall enabled for your account (owner-managed, per-tier or per-client), notes also sync to your team's shared pool — `note add` pushes in the background, `recall` always pulls the team's notes fresh before searching, so a manager's verify/delete in Console is visible on that very search. (Ticket-brief injection uses a separate, short-timeout 4h-cached pull so it never slows down your everyday `ticketlens PROJ-123` — it doesn't need to be instant the way an explicit search does.) A team manager reviews and verifies incoming notes at `console/admin/recall` before they're marked trusted. Without Team Recall entitlement, everything stays on your machine — no network call.
406
+ **Team sync:** included by default on Team/Enterprise; available on Pro too, as a separate add-on (owner-managed per-client, in case the default ever needs to flex). With it enabled, notes also sync to your team's shared pool — `note add` pushes in the background, `recall` always pulls the team's notes fresh before searching, so a manager's verify/delete in Console is visible on that very search. (Ticket-brief injection uses a separate, short-timeout 4h-cached pull so it never slows down your everyday `ticketlens PROJ-123` — it doesn't need to be instant the way an explicit search does.) A team manager reviews and verifies incoming notes at `console/admin/recall` before they're marked trusted. Without Team Recall entitlement, everything stays on your machine — no network call.
405
407
 
406
408
  **Offline resilience:** if a team push fails for a transient reason (network error, timeout, or a 5xx from the backend), the note stays safely in your local vault and is queued for retry — nothing is lost. The queue flushes automatically in the background before every command, not just `recall`/`note add` — a short timeout on that check means it never stalls an unrelated command — or on demand with `ticketlens recall sync`. A session-expired (401) or not-entitled (403) push is never queued — those need you to act (`ticketlens login`, or an owner grant), not a retry. Switching accounts never flushes a note under the wrong login.
407
409
 
@@ -595,6 +597,7 @@ ticketlens CNV1-2 --template=quick # Apply quick template (meta + 2
595
597
  ticketlens CNV1-2 --template=code-review # Apply code-review template (meta + desc + linked + code refs)
596
598
  ticketlens CNV1-2 --template=full # Apply full template (all sections, default)
597
599
  ticketlens CNV1-2 --template=my-team-template # Apply a custom team template [Team]
600
+ ticketlens CNV1-2 --budget=8000 # Trim brief to fit a token budget [Pro]
598
601
  ticketlens CNV1-2 --depth=2 --profile=acme --plain # Combine flags freely
599
602
 
600
603
  # Pipe plain output to clipboard, LLM, or file
@@ -675,7 +678,7 @@ ticketlens cache clear --older-than=30d --yes # Skip confirmation (CI
675
678
  ticketlens schedule # Interactive wizard — set time, timezone, profile [Pro]
676
679
  ticketlens schedule --stop # Cancel the scheduled digest [Pro]
677
680
  ticketlens schedule --status # Show current schedule [Pro]
678
- ticketlens schedule --local # Local-only cron/LaunchAgent — no Console auth needed [Pro]
681
+ ticketlens schedule --local --time=07:00 --save=./triage.txt # Local-only cron/LaunchAgent, no Console auth [Pro]
679
682
 
680
683
  # ── History ───────────────────────────────────────────────────────────────────
681
684
  ticketlens history <TICKET-KEY> # Show urgency timeline for a ticket [Pro]
@@ -685,8 +688,8 @@ echo "note body" | ticketlens note add --title="..." --ticket=CNV1-2 --tags=a,b
685
688
  ticketlens note delete --id="..." --ticket=CNV1-2 # Remove a note from your local vault [Pro]
686
689
  ticketlens recall CNV1-2 # Search saved notes by ticket key [Pro]
687
690
  ticketlens recall "retry backoff" # Free-text search across all notes [Pro]
688
- ticketlens recall sync # Retry any notes stuck in the local queue [Pro]
689
- ticketlens recall settings # Show effective retry-queue settings, fetched live [Pro]
691
+ ticketlens recall sync # Retry any notes stuck in the local queue [Team+]
692
+ ticketlens recall settings # Show effective retry-queue settings, fetched live [Team+]
690
693
 
691
694
  # ── Stats ──────────────────────────────────────────────────────────────────────
692
695
  ticketlens stats # Response-time metrics from local history
@@ -24,6 +24,7 @@ import {
24
24
  printProfilesHelp, printScheduleHelp,
25
25
  printInitHelp, printSwitchHelp, printConfigHelp,
26
26
  printReviewHelp, printStandupHelp, printUpdateSkillHelp,
27
+ printComplianceHelp, printLedgerHelp, printPrHelp, printInstallHooksHelp,
27
28
  printCollisionsHelp, printStatsHelp,
28
29
  printCloudKeysHelp,
29
30
  printNoteHelp, printRecallHelp,
@@ -394,10 +395,28 @@ switch (command) {
394
395
  break;
395
396
  }
396
397
 
397
- const { runScheduleWizard } = await import('../skills/jtb/scripts/lib/schedule-wizard.mjs');
398
+ const cliTokenForSchedule = readCliToken();
399
+ const { shouldUseLocalSchedule, parseLocalScheduleFlags, runScheduleLocal, runScheduleWizard } = await import('../skills/jtb/scripts/lib/schedule-wizard.mjs');
400
+
401
+ if (shouldUseLocalSchedule({ cmdArgs, cliToken: cliTokenForSchedule })) {
402
+ const { time, outputFile } = parseLocalScheduleFlags(cmdArgs);
403
+ if (!time || !outputFile) {
404
+ process.stderr.write(` Local scheduling requires --time and --save.\n`);
405
+ process.stderr.write(` Usage: ticketlens schedule --local --time=HH:MM --save=FILE\n`);
406
+ if (!cliTokenForSchedule) {
407
+ process.stderr.write(` Or run \`ticketlens login\` first to use the Console-backed wizard.\n`);
408
+ }
409
+ process.exitCode = 1;
410
+ break;
411
+ }
412
+ const localResult = await runScheduleLocal({ answers: { time, outputFile } });
413
+ if (!localResult.ok) { process.exitCode = 1; }
414
+ break;
415
+ }
416
+
398
417
  const { promptScheduleAnswers } = await import('../skills/jtb/scripts/lib/prompt-helpers.mjs');
399
418
  const answers = await promptScheduleAnswers(cmdArgs);
400
- const result = await runScheduleWizard({ answers, cliToken: readCliToken() });
419
+ const result = await runScheduleWizard({ answers, cliToken: cliTokenForSchedule });
401
420
  if (!result.ok) { process.exitCode = 1; break; }
402
421
 
403
422
  const s = createStyler({ isTTY: process.stdout.isTTY });
@@ -410,6 +429,7 @@ switch (command) {
410
429
  }
411
430
 
412
431
  case 'install-hooks':
432
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printInstallHooksHelp(); break; }
413
433
  runFetch(['install-hooks', ...cmdArgs]).catch(err => {
414
434
  process.stderr.write(`Error: ${err.message}\n`);
415
435
  process.exitCode = 1;
@@ -417,6 +437,7 @@ switch (command) {
417
437
  break;
418
438
 
419
439
  case 'pr':
440
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printPrHelp(); break; }
420
441
  runFetch(['pr', ...cmdArgs]).catch(err => {
421
442
  process.stderr.write(`Error: ${err.message}\n`);
422
443
  process.exitCode = 1;
@@ -440,6 +461,7 @@ switch (command) {
440
461
  break;
441
462
 
442
463
  case 'ledger':
464
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printLedgerHelp(); break; }
443
465
  runFetch(['ledger', ...cmdArgs]).catch(err => {
444
466
  process.stderr.write(`Error: ${err.message}\n`);
445
467
  process.exitCode = 1;
@@ -447,6 +469,7 @@ switch (command) {
447
469
  break;
448
470
 
449
471
  case 'compliance':
472
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printComplianceHelp(); break; }
450
473
  runFetch(['compliance', ...cmdArgs]).catch(err => {
451
474
  process.stderr.write(`Error: ${err.message}\n`);
452
475
  process.exitCode = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.21.9",
3
+ "version": "0.21.11",
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": {
@@ -44,6 +44,10 @@ Fetches a Jira ticket and produces a structured brief with code references, then
44
44
  /jtb stats --format=json # JSON output for scripting
45
45
  /jtb collisions # show branch collisions with teammates (Team)
46
46
  /jtb collisions --json # machine-readable output
47
+ /jtb review # code-review context brief from current branch
48
+ /jtb review --branch=main # compare against a specific branch
49
+ /jtb standup # standup summary from git log (last 24h)
50
+ /jtb standup --format=pr # PR body instead of standup bullets
47
51
  /jtb cloud-keys list # list configured AI provider keys (Pro)
48
52
  /jtb cloud-keys add groq gsk_xxxx # add Groq key (free tier — console.groq.com)
49
53
  /jtb cloud-keys add anthropic sk-ant-x # add Anthropic key
@@ -52,10 +56,10 @@ Fetches a Jira ticket and produces a structured brief with code references, then
52
56
  /jtb cloud-keys remove groq # remove a provider
53
57
  /jtb cloud-keys priority groq 1 # set provider priority (lower = tried first)
54
58
  /jtb cloud-keys timeout anthropic 15 # set per-request timeout in seconds
55
- /jtb note "gotcha text" --ticket=PROD-1234 # save a Recall note (Pro)
59
+ /jtb note add --title="gotcha text" --ticket=PROD-1234 # save a Recall note (Pro, body from stdin)
56
60
  /jtb recall PROD-1234 # search saved Recall notes (Pro)
57
- /jtb recall sync # retry any notes stuck in the local queue (Pro)
58
- /jtb recall settings # show effective retry-queue settings, fetched live (Pro)
61
+ /jtb recall sync # retry any notes stuck in the local queue (Team+)
62
+ /jtb recall settings # show effective retry-queue settings, fetched live (Team+)
59
63
  ```
60
64
 
61
65
  ## Prerequisites
@@ -246,7 +250,7 @@ This never calls any external API or bills any tokens beyond the session you alr
246
250
  **Known limitation:** `note patch` only updates the local vault copy. If `note add` already pushed the original draft to a team (Team Recall enabled), a later refinement from this loop is *not* re-pushed — teammates who already pulled the note keep the original draft until this is addressed in a future iteration.
247
251
 
248
252
  ### Privacy
249
- Recall notes are stored locally at `~/.ticketlens/recall/`. On a Free/Pro account with no Team Recall entitlement, they never leave the machine — no network calls. On a Team account with Recall enabled (owner-managed, may vary per user), notes also sync to the team's shared pool in the background so teammates can benefit from them too; a team manager reviews and verifies each incoming note before it's marked trusted. If a team push fails for a transient reason (network error, timeout, 5xx), the note is queued locally and retried automatically in the background, or on demand with `ticketlens recall sync` — a session-expired or not-entitled push is never queued, since retrying those can't succeed without the user acting first.
253
+ Recall notes are stored locally at `~/.ticketlens/recall/`. On a Pro account with no Team Recall entitlement, they never leave the machine — no network calls (Free tier can't use Recall at all). On Team/Enterprise, Recall's team sync is included by default (Pro accounts can get it too, as a separate add-on); notes also sync to the team's shared pool in the background so teammates can benefit from them too, and a team manager reviews and verifies each incoming note before it's marked trusted. If a team push fails for a transient reason (network error, timeout, 5xx), the note is queued locally and retried automatically in the background, or on demand with `ticketlens recall sync` [Team+] — a session-expired or not-entitled push is never queued, since retrying those can't succeed without the user acting first.
250
254
 
251
255
  ---
252
256
 
@@ -493,6 +493,22 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
493
493
 
494
494
  // Early dispatch for non-ticket subcommands
495
495
  if (args[0] === 'install-hooks') {
496
+ if (args.includes('--uninstall')) {
497
+ const { uninstallHook } = await import('./lib/hook-installer.mjs');
498
+ try {
499
+ const result = await uninstallHook({ cwd: process.cwd() });
500
+ if (result.skipped) {
501
+ process.stderr.write(` Hook uninstall skipped: ${result.reason}\n`);
502
+ } else {
503
+ process.stdout.write(` Hook removed: ${result.path}\n`);
504
+ }
505
+ } catch (err) {
506
+ process.stderr.write(` Error removing hook: ${err.message}\n`);
507
+ process.exitCode = 1;
508
+ }
509
+ return;
510
+ }
511
+
496
512
  const { installHook } = await import('./lib/hook-installer.mjs');
497
513
  try {
498
514
  const result = await installHook({ cwd: process.cwd() });
@@ -43,23 +43,30 @@ export function printHelp({ stream = process.stdout } = {}) {
43
43
  ` ${s.brand('ticketlens')} review ${s.dim('[--branch=BRANCH]')} Code-review context brief from current branch`,
44
44
  ` ${s.brand('ticketlens')} standup ${s.dim('[--since=N]')} Standup summary from git log ${s.dim('(last 24h by default)')}`,
45
45
  ` ${s.brand('ticketlens')} compliance ${s.dim('<TICKET-KEY>')} Check requirements coverage ${s.dim('[Pro/Free 3/mo]')}`,
46
+ ` ${s.brand('ticketlens')} install-hooks ${s.dim('[--uninstall]')} Git pre-push compliance gate`,
47
+ ` ${s.brand('ticketlens')} pr ${s.dim('<TICKET-KEY>')} Assemble a PR description from ticket context`,
48
+ ` ${s.brand('ticketlens')} ledger ${s.dim('[--format=json|csv]')} Export your signed usage ledger ${s.dim('[Pro]')}`,
46
49
  ` ${s.brand('ticketlens')} history ${s.dim('<TICKET-KEY>')} Urgency timeline for a ticket ${s.dim('[Pro]')}`,
47
50
  ` ${s.brand('ticketlens')} stats ${s.dim('[options]')} Personal response-time metrics from local history`,
48
51
  ` ${s.brand('ticketlens')} note add ${s.dim('--title=... [--ticket=KEY]')} Save a Recall note ${s.dim('[Pro]')}`,
49
52
  ` ${s.brand('ticketlens')} note delete ${s.dim('--id=... [--ticket=KEY]')} Remove a note from your local vault ${s.dim('[Pro]')}`,
50
53
  ` ${s.brand('ticketlens')} recall ${s.dim('<query|TICKET-KEY>')} Search your saved Recall notes ${s.dim('[Pro]')}`,
54
+ ` ${s.brand('ticketlens')} recall sync Retry any notes stuck in the local queue ${s.dim('[Team+]')}`,
55
+ ` ${s.brand('ticketlens')} recall settings Show effective retry-queue settings, fetched live ${s.dim('[Team+]')}`,
51
56
  '',
52
57
  ` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
53
58
  ` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
54
59
  ` ${s.brand('ticketlens')} license Show license status`,
55
60
  ` ${s.brand('ticketlens')} cache ${s.dim('[size|clear]')} Manage attachment cache ${s.dim('(try cache --help)')}`,
56
- ` ${s.brand('ticketlens')} schedule ${s.dim('[--stop|--status]')} Manage digest schedule ${s.dim('[Pro]')}`,
61
+ ` ${s.brand('ticketlens')} schedule ${s.dim('[--stop|--status|--local]')} Manage digest schedule ${s.dim('[Pro]')}`,
57
62
  ` ${s.brand('ticketlens')} cloud-keys ${s.dim('[add|remove|list|test]')} Manage your encrypted AI provider keys ${s.dim('[Pro]')}`,
58
63
  ` ${s.brand('ticketlens')} update-skill ${s.dim('[--dry-run]')} Update /jtb skill in Claude Code and other AI assistants`,
59
64
  '',
60
65
  ` ${s.bold('GLOBAL OPTIONS')}`,
61
66
  '',
62
67
  ` ${s.brand('--no-input')} Force non-interactive behavior even in a terminal`,
68
+ ` ${s.brand('-v')}, ${s.brand('--version')} Show version and exit ${s.dim('(same as ticketlens version)')}`,
69
+ ` ${s.brand('clear')} Alias for ${s.brand('cache clear')}`,
63
70
  '',
64
71
  ` ${s.bold('FETCH OPTIONS')}`,
65
72
  '',
@@ -78,6 +85,7 @@ export function printHelp({ stream = process.stdout } = {}) {
78
85
  ` ${s.brand('--cloud')} Route AI request through TicketLens API ${s.dim('[Pro]')}`,
79
86
  ` ${s.brand('--provider')}=${s.dim('NAME')} Force AI provider ${s.dim('(anthropic|openai|groq)')}`,
80
87
  ` ${s.brand('--template')}=${s.dim('SLUG')} Apply a brief template ${s.dim('(full|quick|code-review, or custom [Team])')}`,
88
+ ` ${s.brand('--budget')}=${s.dim('N')} Trim brief to fit a token budget ${s.dim('[Pro]')}`,
81
89
  '',
82
90
  ` ${s.bold('TRIAGE OPTIONS')}`,
83
91
  '',
@@ -100,6 +108,7 @@ export function printHelp({ stream = process.stdout } = {}) {
100
108
  ` ${s.brand('--digest')} POST scored results to digest endpoint ${s.dim('[Pro]')}`,
101
109
  ` ${s.brand('--static')} Static table output ${s.dim('(skip interactive mode)')}`,
102
110
  ` ${s.brand('--plain')} Plain markdown output ${s.dim('(for piping / LLM)')}`,
111
+ ` ${s.brand('--styled')} Force ANSI-styled table output`,
103
112
  '',
104
113
  ` ${s.bold('EXAMPLES')}`,
105
114
  '',
@@ -444,15 +453,23 @@ export function printScheduleHelp({ stream = process.stdout } = {}) {
444
453
  const s = createStyler({ isTTY: stream.isTTY });
445
454
  const lines = [
446
455
  '',
447
- ` ${s.bold(s.brand('ticketlens'))} ${s.bold('schedule')} ${s.dim('[--stop|--status]')} ${s.dim('[Pro]')}`,
456
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('schedule')} ${s.dim('[--stop|--status|--local]')} ${s.dim('[Pro]')}`,
448
457
  '',
449
458
  ` Set up a recurring digest email with your triage results. ${s.dim('[Pro]')}`,
450
459
  ` Runs an interactive wizard to configure day, time, and timezone.`,
460
+ ` Without Console login, falls back to local-only scheduling automatically —`,
461
+ ` no interactive wizard, no digest email, just a cron/LaunchAgent entry that`,
462
+ ` writes triage output to a file.`,
451
463
  '',
452
464
  ` ${s.bold('OPTIONS')}`,
453
465
  '',
454
466
  ` ${s.brand('--stop')} Cancel the active digest schedule`,
455
467
  ` ${s.brand('--status')} Show current schedule configuration`,
468
+ ` ${s.brand('--time')}=${s.dim('HH:MM')} Pre-fill delivery/run time ${s.dim('(skips the interactive prompt)')}`,
469
+ ` ${s.brand('--email')}=${s.dim('ADDR')} Pre-fill delivery email ${s.dim('(Console-backed wizard only)')}`,
470
+ ` ${s.brand('--timezone')}=${s.dim('TZ')} Pre-fill timezone ${s.dim('(Console-backed wizard only)')}`,
471
+ ` ${s.brand('--local')} Local-only cron/LaunchAgent, no Console auth ${s.dim('(auto-detected when logged out)')}`,
472
+ ` ${s.brand('--save')}=${s.dim('FILE')} Output file for local mode ${s.dim('(required with --local)')}`,
456
473
  ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
457
474
  '',
458
475
  ` ${s.bold('EXAMPLES')}`,
@@ -460,6 +477,7 @@ export function printScheduleHelp({ stream = process.stdout } = {}) {
460
477
  ` ${s.dim('$')} ticketlens schedule`,
461
478
  ` ${s.dim('$')} ticketlens schedule --status`,
462
479
  ` ${s.dim('$')} ticketlens schedule --stop`,
480
+ ` ${s.dim('$')} ticketlens schedule --local --time=07:00 --save=./triage.txt`,
463
481
  '',
464
482
  ];
465
483
  stream.write(lines.join('\n') + '\n');
@@ -537,7 +555,9 @@ export function printRecallHelp({ stream = process.stdout } = {}) {
537
555
  '',
538
556
  ` ${s.bold(s.brand('ticketlens'))} ${s.bold('recall')} ${s.dim('<query|TICKET-KEY>')} ${s.dim('[Pro]')}`,
539
557
  '',
540
- ` Search your saved Recall notes. Local only — no network calls. ${s.dim('[Pro]')}`,
558
+ ` Search your saved Recall notes. ${s.dim('[Pro]')}`,
559
+ ` Works fully offline if you have no team. Logged in with team sync enabled,`,
560
+ ` pulls the team's notes fresh before every search.`,
541
561
  '',
542
562
  ` ${s.bold('OPTIONS')}`,
543
563
  '',
@@ -547,8 +567,10 @@ export function printRecallHelp({ stream = process.stdout } = {}) {
547
567
  '',
548
568
  ` ${s.bold('COMMANDS')}`,
549
569
  '',
550
- ` ${s.brand('sync')} Manually retry any team-synced notes stuck in the local retry queue ${s.dim('[Pro, requires login]')}`,
551
- ` ${s.brand('settings')} Show effective retry-queue settings (cooldown, timeout, queue limits) ${s.dim('[Pro]')}`,
570
+ ` ${s.brand('sync')} Manually retry any team-synced notes stuck in the local retry queue ${s.dim('[Team+, requires login]')}`,
571
+ ` ${s.brand('settings')} Show effective retry-queue settings (cooldown, timeout, queue limits) ${s.dim('[Team+]')}`,
572
+ '',
573
+ ` ${s.dim('Team+ = included on Team/Enterprise; available on Pro as a separate Recall add-on.')}`,
552
574
  '',
553
575
  ` ${s.bold('EXAMPLES')}`,
554
576
  '',
@@ -710,6 +732,108 @@ export function printReviewHelp({ stream = process.stdout } = {}) {
710
732
  stream.write(lines.join('\n') + '\n');
711
733
  }
712
734
 
735
+ export function printComplianceHelp({ stream = process.stdout } = {}) {
736
+ const s = createStyler({ isTTY: stream.isTTY });
737
+ const lines = [
738
+ '',
739
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('compliance')} ${s.dim('<TICKET-KEY> [--profile=NAME]')} ${s.dim('[Pro/Free 3/mo]')}`,
740
+ '',
741
+ ` Check your current branch's diff against the ticket's requirements.`,
742
+ ` Extracts candidate requirements from the ticket description and diffs`,
743
+ ` them against what the local git diff actually covers, reporting a`,
744
+ ` coverage percentage and a list of uncovered items.`,
745
+ '',
746
+ ` Used internally by the pre-push hook installed via ${s.brand('ticketlens install-hooks')}`,
747
+ ` (reads the threshold from ${s.dim('.ticketlens-hooks.json')}, default 80%).`,
748
+ '',
749
+ ` ${s.bold('OPTIONS')}`,
750
+ '',
751
+ ` ${s.brand('--profile')}=${s.dim('NAME')} Use a specific Jira profile`,
752
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
753
+ '',
754
+ ` ${s.bold('EXAMPLES')}`,
755
+ '',
756
+ ` ${s.dim('$')} ticketlens compliance PROJ-123`,
757
+ ` ${s.dim('$')} ticketlens compliance PROJ-123 --profile=myteam`,
758
+ '',
759
+ ];
760
+ stream.write(lines.join('\n') + '\n');
761
+ }
762
+
763
+ export function printLedgerHelp({ stream = process.stdout } = {}) {
764
+ const s = createStyler({ isTTY: stream.isTTY });
765
+ const lines = [
766
+ '',
767
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('ledger')} ${s.dim('[--format=json|csv]')} ${s.dim('[Pro]')}`,
768
+ '',
769
+ ` Export your local usage ledger — a signed, tamper-evident record of`,
770
+ ` billable actions (AI calls, exports, etc). Verifiable offline via an`,
771
+ ` HMAC-SHA256 signature over {records, exportedAt}, keyed at ledger-key.`,
772
+ '',
773
+ ` ${s.bold('OPTIONS')}`,
774
+ '',
775
+ ` ${s.brand('--format')}=${s.dim('json')} Full record export with signature ${s.dim('(default)')}`,
776
+ ` ${s.brand('--format')}=${s.dim('csv')} Flat CSV, no signature`,
777
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
778
+ '',
779
+ ` ${s.bold('EXAMPLES')}`,
780
+ '',
781
+ ` ${s.dim('$')} ticketlens ledger`,
782
+ ` ${s.dim('$')} ticketlens ledger --format=csv`,
783
+ '',
784
+ ];
785
+ stream.write(lines.join('\n') + '\n');
786
+ }
787
+
788
+ export function printPrHelp({ stream = process.stdout } = {}) {
789
+ const s = createStyler({ isTTY: stream.isTTY });
790
+ const lines = [
791
+ '',
792
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('pr')} ${s.dim('<TICKET-KEY> [--profile=NAME]')}`,
793
+ '',
794
+ ` Assemble a pull-request description from a ticket's context —`,
795
+ ` summary, acceptance criteria, and linked issues, formatted as a`,
796
+ ` ready-to-paste PR body.`,
797
+ '',
798
+ ` ${s.bold('OPTIONS')}`,
799
+ '',
800
+ ` ${s.brand('--profile')}=${s.dim('NAME')} Use a specific Jira profile`,
801
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
802
+ '',
803
+ ` ${s.bold('EXAMPLES')}`,
804
+ '',
805
+ ` ${s.dim('$')} ticketlens pr PROJ-123`,
806
+ ` ${s.dim('$')} ticketlens pr PROJ-123 --profile=myteam`,
807
+ '',
808
+ ];
809
+ stream.write(lines.join('\n') + '\n');
810
+ }
811
+
812
+ export function printInstallHooksHelp({ stream = process.stdout } = {}) {
813
+ const s = createStyler({ isTTY: stream.isTTY });
814
+ const lines = [
815
+ '',
816
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('install-hooks')} ${s.dim('[--uninstall]')}`,
817
+ '',
818
+ ` Installs a git pre-push hook that blocks a push when this branch's`,
819
+ ` compliance coverage (see ${s.brand('ticketlens compliance')}) is below 80%.`,
820
+ ` Appends to an existing pre-push hook rather than overwriting it;`,
821
+ ` calling it again is a no-op if already installed.`,
822
+ '',
823
+ ` ${s.bold('OPTIONS')}`,
824
+ '',
825
+ ` ${s.brand('--uninstall')} Remove the hook, restoring any pre-existing pre-push content`,
826
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
827
+ '',
828
+ ` ${s.bold('EXAMPLES')}`,
829
+ '',
830
+ ` ${s.dim('$')} ticketlens install-hooks`,
831
+ ` ${s.dim('$')} ticketlens install-hooks --uninstall`,
832
+ '',
833
+ ];
834
+ stream.write(lines.join('\n') + '\n');
835
+ }
836
+
713
837
  export function printUpdateSkillHelp({ stream = process.stdout } = {}) {
714
838
  const s = createStyler({ isTTY: stream.isTTY });
715
839
  const lines = [
@@ -79,3 +79,51 @@ export function installHook({ cwd = process.cwd(), threshold = 80, fsModule = fs
79
79
 
80
80
  return { installed: true, path: hookPath };
81
81
  }
82
+
83
+ /**
84
+ * Removes a previously-installed compliance gate, leaving any pre-existing
85
+ * (non-ticketlens) pre-push hook content untouched. installHook() always
86
+ * appends its block last, prefixed with its own `#!/bin/sh` line — this
87
+ * strips from that shebang line (and one preceding blank line from the
88
+ * append's leading '\n', if present) through end of file. If nothing
89
+ * precedes that point, the file is deleted rather than left empty.
90
+ *
91
+ * @param {{ cwd?: string, fsModule?: typeof import('node:fs') }} [opts]
92
+ * @returns {{ uninstalled: true, path: string } | { skipped: true, reason: string }}
93
+ */
94
+ export function uninstallHook({ cwd = process.cwd(), fsModule = fs } = {}) {
95
+ const hookPath = join(cwd, '.git', 'hooks', 'pre-push');
96
+
97
+ let existing;
98
+ try {
99
+ existing = fsModule.readFileSync(hookPath, 'utf8');
100
+ } catch (err) {
101
+ if (err.code === 'ENOENT') return { skipped: true, reason: 'No pre-push hook installed.' };
102
+ throw err;
103
+ }
104
+
105
+ if (!existing.includes(GUARD)) {
106
+ return { skipped: true, reason: 'No ticketlens compliance gate found in the pre-push hook.' };
107
+ }
108
+
109
+ const guardIdx = existing.indexOf(GUARD);
110
+ const shebangIdx = existing.lastIndexOf('#!/bin/sh\n', guardIdx);
111
+ let cutStart = shebangIdx;
112
+ if (cutStart > 0 && existing[cutStart - 1] === '\n') cutStart -= 1;
113
+
114
+ const remaining = existing.slice(0, cutStart);
115
+ if (remaining.trim() === '') {
116
+ fsModule.unlinkSync(hookPath);
117
+ } else {
118
+ fsModule.writeFileSync(hookPath, remaining, 'utf8');
119
+ }
120
+
121
+ const configPath = join(cwd, '.ticketlens-hooks.json');
122
+ try {
123
+ fsModule.unlinkSync(configPath);
124
+ } catch (err) {
125
+ if (err.code !== 'ENOENT') throw err;
126
+ }
127
+
128
+ return { uninstalled: true, path: hookPath };
129
+ }
@@ -252,6 +252,33 @@ export function buildLocalCronLine({ hour, minute, ticketlensBin, outputFile })
252
252
  return `${minute} ${hour} * * * ${ticketlensBin} triage '--save=${outputFile}' >> '${outputFile}.err' 2>&1`;
253
253
  }
254
254
 
255
+ /**
256
+ * Decides whether `ticketlens schedule` should use the local-only path
257
+ * (runScheduleLocal) instead of the Console-backed wizard (runScheduleWizard)
258
+ * — explicit `--local`, or auto-detected whenever there's no cliToken at all,
259
+ * so a logged-out user gets a working local schedule instead of a hard
260
+ * "log in first" error.
261
+ *
262
+ * @param {{ cmdArgs?: string[], cliToken?: string|null }} opts
263
+ * @returns {boolean}
264
+ */
265
+ export function shouldUseLocalSchedule({ cmdArgs = [], cliToken } = {}) {
266
+ return cmdArgs.includes('--local') || !cliToken;
267
+ }
268
+
269
+ /**
270
+ * Extracts the --time= and --save= flags local scheduling needs. Both are
271
+ * required by runScheduleLocal itself; this just does the string parsing.
272
+ *
273
+ * @param {string[]} cmdArgs
274
+ * @returns {{ time?: string, outputFile?: string }}
275
+ */
276
+ export function parseLocalScheduleFlags(cmdArgs = []) {
277
+ const time = cmdArgs.find(a => a.startsWith('--time='))?.split('=')[1];
278
+ const outputFile = cmdArgs.find(a => a.startsWith('--save='))?.split('=')[1];
279
+ return { time, outputFile };
280
+ }
281
+
255
282
  /**
256
283
  * Set up a local-only scheduled triage (no Console auth, no cloud push).
257
284
  * Writes a cron/LaunchAgent entry that runs `ticketlens triage --save=FILE`.