ticketlens 0.16.1 → 0.18.0

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
@@ -394,10 +394,14 @@ Save short notes to yourself — gotchas, context, decisions — and they're aut
394
394
 
395
395
  The note body is read from stdin, not a flag — this avoids shell-quoting issues with multi-line text. A note can be tied to one ticket (`--ticket=KEY`), or left general (omit `--ticket`) for onboarding-style knowledge that isn't about a specific ticket. Add `--include-attachments` to seed the note with text from that ticket's already-cached attachments (`.txt`/`.md`/`.csv`/`.json` only).
396
396
 
397
- Every note is scanned before saving — anything shaped like a real secret (API key, private key, token) is rejected outright, never silently redacted. Requires a Pro license.
397
+ Every note is scanned before saving — anything shaped like a real secret (API key, private key, token) is rejected outright, never silently redacted. An empty, placeholder (`TODO`, `WIP`, …), or too-short body is rejected the same way. Requires a Pro license.
398
+
399
+ **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.
398
400
 
399
401
  **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` pulls the team's notes (cached 4h) before searching. 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.
400
402
 
403
+ **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 (at most once every 15 minutes, whenever `recall` or `note add` next talks to the network), 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. Queued entries expire after 30 days and are capped at 200; switching accounts never flushes a note under the wrong login.
404
+
401
405
  `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.
402
406
 
403
407
  **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.
@@ -672,6 +676,7 @@ ticketlens history <TICKET-KEY> # Show urgency timeline for a tick
672
676
  echo "note body" | ticketlens note add --title="..." --ticket=CNV1-2 --tags=a,b # Save a note [Pro]
673
677
  ticketlens recall CNV1-2 # Search saved notes by ticket key [Pro]
674
678
  ticketlens recall "retry backoff" # Free-text search across all notes [Pro]
679
+ ticketlens recall sync # Retry any notes stuck in the local queue [Pro]
675
680
 
676
681
  # ── Stats ──────────────────────────────────────────────────────────────────────
677
682
  ticketlens stats # Response-time metrics from local history
@@ -750,6 +755,7 @@ ticketlens triage --digest # POST scored triage results to digest
750
755
  ticketlens schedule # Set up a scheduled daily digest
751
756
  ticketlens note add --title="..." # Save a Recall note (body from stdin)
752
757
  ticketlens recall <query|TICKET-KEY> # Search your saved Recall notes
758
+ ticketlens recall sync # Retry any notes stuck in the local queue
753
759
  ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
754
760
  ```
755
761
 
@@ -610,23 +610,43 @@ switch (command) {
610
610
 
611
611
  case 'note': {
612
612
  if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printNoteHelp(); break; }
613
- if (cmdArgs[0] !== 'add') {
614
- process.stderr.write('Usage: ticketlens note add --title="..." [--ticket=KEY] [--tags=a,b]\n');
615
- process.exitCode = 1;
613
+ if (cmdArgs[0] === 'add') {
614
+ const { runNoteAdd } = await import('../skills/jtb/scripts/lib/note-command.mjs');
615
+ runNoteAdd(cmdArgs.slice(1)).then(({ written }) => {
616
+ if (!written) process.exitCode = 1;
617
+ }).catch(err => {
618
+ process.stderr.write(`Error: ${err.message}\n`);
619
+ process.exitCode = 1;
620
+ });
616
621
  break;
617
622
  }
618
- const { runNoteAdd } = await import('../skills/jtb/scripts/lib/note-command.mjs');
619
- runNoteAdd(cmdArgs.slice(1)).then(({ written }) => {
620
- if (!written) process.exitCode = 1;
621
- }).catch(err => {
622
- process.stderr.write(`Error: ${err.message}\n`);
623
- process.exitCode = 1;
624
- });
623
+ if (cmdArgs[0] === 'patch') {
624
+ const { runNotePatch } = await import('../skills/jtb/scripts/lib/note-command.mjs');
625
+ runNotePatch(cmdArgs.slice(1)).then(({ patched }) => {
626
+ if (!patched) process.exitCode = 1;
627
+ }).catch(err => {
628
+ process.stderr.write(`Error: ${err.message}\n`);
629
+ process.exitCode = 1;
630
+ });
631
+ break;
632
+ }
633
+ process.stderr.write('Usage: ticketlens note add --title="..." [--ticket=KEY] [--tags=a,b]\n');
634
+ process.exitCode = 1;
625
635
  break;
626
636
  }
627
637
 
628
638
  case 'recall': {
629
639
  if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printRecallHelp(); break; }
640
+ if (cmdArgs[0] === 'sync') {
641
+ const { runRecallSync } = await import('../skills/jtb/scripts/lib/recall-command.mjs');
642
+ runRecallSync(cmdArgs.slice(1)).then(({ ok }) => {
643
+ if (!ok) process.exitCode = 1;
644
+ }).catch(err => {
645
+ process.stderr.write(`Error: ${err.message}\n`);
646
+ process.exitCode = 1;
647
+ });
648
+ break;
649
+ }
630
650
  const { runRecall } = await import('../skills/jtb/scripts/lib/recall-command.mjs');
631
651
  runRecall(cmdArgs).then(({ ok }) => {
632
652
  if (!ok) process.exitCode = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
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.16.1 -->
1
+ <!-- jtb-skill-version: 0.18.0 -->
2
2
  ---
3
3
  name: jtb
4
4
  description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
@@ -53,6 +53,7 @@ Fetches a Jira ticket and produces a structured brief with code references, then
53
53
  /jtb cloud-keys timeout anthropic 15 # set per-request timeout in seconds
54
54
  /jtb note "gotcha text" --ticket=PROD-1234 # save a Recall note (Pro)
55
55
  /jtb recall PROD-1234 # search saved Recall notes (Pro)
56
+ /jtb recall sync # retry any notes stuck in the local queue (Pro)
56
57
  ```
57
58
 
58
59
  ## Prerequisites
@@ -219,8 +220,29 @@ echo "The body text of the note, one or more paragraphs." | \
219
220
 
220
221
  To search saved notes directly (outside of automatic brief injection): `ticketlens recall "<query>"`.
221
222
 
223
+ ### Quality loop (Pro, in-session only)
224
+
225
+ Only when `note add` above was dispatched *by you, inside this skill*, and it printed a saved note id (e.g. `Saved note "Retry gotcha" (1784135399545-fe01c4.md)`) — never for a note a user typed directly into a bare shell, which has no Task/Agent tool available. If there's no such tool in your environment, skip this whole section silently: no warning, no degraded fallback, the note is already saved and that's a complete, correct outcome on its own.
226
+
227
+ When it does apply, run up to 3 rounds:
228
+
229
+ 1. **Capture the current state** before dispatching anything: get the note file's (`~/.ticketlens/recall/<PREFIX-or-_general>/<id>`) current mtime in epoch **milliseconds** — the shell `stat` command reports seconds on both macOS and Linux, which is the wrong unit and will make every patch silently no-op. Use `node -e "console.log(require('fs').statSync('PATH').mtimeMs)"` instead (Node is already required to run `ticketlens`). Also read the note's current body.
230
+ 2. **Generator** — a subagent drafts an improved body: concrete file/line references over vague prose, no invented facts not already established this session.
231
+ 3. **Validator** — a separate subagent scores the draft against two criteria: **actionability** (does it read like something a future session could act on directly?) and **non-duplication** (run `ticketlens recall "<query>" --ticket=TICKET-KEY` against the ticket this note is about — reject/rescore a draft that's a near-duplicate of an existing note).
232
+ 4. If the draft scores as a genuine improvement, write it back:
233
+ ```bash
234
+ echo "The improved body text." | \
235
+ ticketlens note patch --id="THE-ID-PRINTED-ABOVE" --ticket=TICKET-KEY --expect-mtime="THE-MTIME-FROM-STEP-1"
236
+ ```
237
+ `--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.
238
+ 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.
239
+
240
+ 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.
241
+
242
+ **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.
243
+
222
244
  ### Privacy
223
- 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.
245
+ 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.
224
246
 
225
247
  ---
226
248
 
@@ -511,6 +511,13 @@ export function printNoteHelp({ stream = process.stdout } = {}) {
511
511
  '',
512
512
  ` ${s.dim('$')} echo "Retry needs exponential backoff" | ticketlens note add --title="Retry gotcha" --ticket=PROD-123 --tags=bug`,
513
513
  '',
514
+ ` ${s.bold('ticketlens note patch')} ${s.dim('--id="..." [--ticket=KEY]')} ${s.dim('[Pro]')}`,
515
+ '',
516
+ ` Overwrites an existing note's body with a better draft, read from stdin.`,
517
+ ` Internal mechanism used by the jtb skill's note quality loop inside a Claude`,
518
+ ` Code session — not typically invoked by hand. Every note it writes gets the`,
519
+ ` same structural and secret-scan checks ${s.brand('note add')} applies to user input.`,
520
+ '',
514
521
  ];
515
522
  stream.write(lines.join('\n') + '\n');
516
523
  }
@@ -529,12 +536,17 @@ export function printRecallHelp({ stream = process.stdout } = {}) {
529
536
  ` ${s.brand('--full')} Print each matching note's full body content`,
530
537
  ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
531
538
  '',
539
+ ` ${s.bold('COMMANDS')}`,
540
+ '',
541
+ ` ${s.brand('sync')} Manually retry any team-synced notes stuck in the local retry queue ${s.dim('[Pro, requires login]')}`,
542
+ '',
532
543
  ` ${s.bold('EXAMPLES')}`,
533
544
  '',
534
545
  ` ${s.dim('$')} ticketlens recall PROD-123`,
535
546
  ` ${s.dim('$')} ticketlens recall "retry backoff"`,
536
547
  ` ${s.dim('$')} ticketlens recall PROD-123 --plain`,
537
548
  ` ${s.dim('$')} ticketlens recall PROD-123 --full`,
549
+ ` ${s.dim('$')} ticketlens recall sync`,
538
550
  '',
539
551
  ];
540
552
  stream.write(lines.join('\n') + '\n');
@@ -10,9 +10,11 @@ import path from 'node:path';
10
10
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
11
11
  import { isLicensed, showUpgradePrompt } from './license.mjs';
12
12
  import { scanForSecrets } from './secret-scanner.mjs';
13
- import { writeNote } from './recall-vault.mjs';
13
+ import { checkNoteStructure } from './note-structural-check.mjs';
14
+ import { writeNote, patchNoteBody } from './recall-vault.mjs';
14
15
  import { readCliToken } from './cli-auth.mjs';
15
16
  import { pushNote } from './recall-sync.mjs';
17
+ import { enqueueNote, isRetryableFailure, maybeAutoFlush } from './recall-queue.mjs';
16
18
  import { incrementDraftKept, incrementDraftDeleted } from './activity-counter.mjs';
17
19
  import { extractText } from './attachment-text.mjs';
18
20
  import { TICKET_KEY_PATTERN } from './cli.mjs';
@@ -66,10 +68,14 @@ export async function runNoteAdd(cmdArgs, {
66
68
  stream = process.stderr,
67
69
  readStdin = defaultReadStdin,
68
70
  isLicensedFn = isLicensed,
71
+ checkNoteStructureFn = checkNoteStructure,
69
72
  scanForSecretsFn = scanForSecrets,
70
73
  writeNoteFn = writeNote,
71
74
  readCliTokenFn = readCliToken,
72
75
  pushNoteFn = pushNote,
76
+ enqueueNoteFn = enqueueNote,
77
+ isRetryableFailureFn = isRetryableFailure,
78
+ maybeAutoFlushFn = maybeAutoFlush,
73
79
  incrementDraftKeptFn = incrementDraftKept,
74
80
  incrementDraftDeletedFn = incrementDraftDeleted,
75
81
  listAttachmentsFn = defaultListAttachments,
@@ -103,6 +109,13 @@ export async function runNoteAdd(cmdArgs, {
103
109
  body += gatherAttachmentExcerpts(configDir, ticketKey, listAttachmentsFn, extractTextFn);
104
110
  }
105
111
 
112
+ const structural = checkNoteStructureFn({ body });
113
+ if (structural.rejected) {
114
+ stream.write(` Note not saved — ${structural.reason}\n`);
115
+ incrementDraftDeletedFn(configDir);
116
+ return { written: false };
117
+ }
118
+
106
119
  const scan = scanForSecretsFn({ title, tags, body });
107
120
  if (scan.rejected) {
108
121
  stream.write(` Note not saved — ${scan.reasons.join(' ')}\n`);
@@ -122,13 +135,78 @@ export async function runNoteAdd(cmdArgs, {
122
135
 
123
136
  const cliToken = readCliTokenFn(configDir);
124
137
  if (cliToken) {
138
+ const warn = (s) => stream.write(s);
125
139
  // Field names match PushRequest's validation rules (external_id, tickets) —
126
140
  // the backend wire contract, not the local vault's internal camelCase shape.
127
- await pushNoteFn(
128
- { external_id: id, title, tickets: ticketKeys, tags, author, sources: [], body },
129
- { cliToken, configDir, warn: (s) => stream.write(s) },
130
- );
141
+ const payload = { external_id: id, title, tickets: ticketKeys, tags, author, sources: [], body };
142
+ const result = await pushNoteFn(payload, { cliToken, configDir, warn });
143
+ if (isRetryableFailureFn(result)) {
144
+ enqueueNoteFn(payload, { cliToken, configDir, warn });
145
+ }
146
+ await maybeAutoFlushFn({ cliToken, configDir });
131
147
  }
132
148
 
133
149
  return { written: true };
134
150
  }
151
+
152
+ /**
153
+ * Implements `tl note patch` — overwrites an existing note's body with a
154
+ * better draft. Internal plumbing for the jtb skill's quality loop (never
155
+ * meant to be typed by hand): the loop's generator subagent produces a body,
156
+ * SKILL.md's orchestration pipes it through this command, and the new body
157
+ * gets exactly the same structural and secret gates a user-typed body gets —
158
+ * there is no weaker path here for AI-authored content.
159
+ *
160
+ * @param {string[]} cmdArgs
161
+ * @returns {Promise<{ patched: boolean }>}
162
+ */
163
+ export async function runNotePatch(cmdArgs, {
164
+ configDir = DEFAULT_CONFIG_DIR,
165
+ stream = process.stderr,
166
+ readStdin = defaultReadStdin,
167
+ isLicensedFn = isLicensed,
168
+ checkNoteStructureFn = checkNoteStructure,
169
+ scanForSecretsFn = scanForSecrets,
170
+ patchNoteBodyFn = patchNoteBody,
171
+ } = {}) {
172
+ if (!isLicensedFn('pro', configDir)) {
173
+ showUpgradePrompt('pro', 'ticketlens note', { stream });
174
+ return { patched: false };
175
+ }
176
+
177
+ const id = parseFlag(cmdArgs, 'id');
178
+ if (!id) {
179
+ stream.write('Usage: ticketlens note patch --id="..." [--ticket=KEY]\n');
180
+ return { patched: false };
181
+ }
182
+
183
+ const ticketKey = parseFlag(cmdArgs, 'ticket');
184
+ if (ticketKey && !TICKET_KEY_PATTERN.test(ticketKey)) {
185
+ stream.write(` Invalid --ticket value "${ticketKey}" — expected a ticket key like PROJ-123.\n`);
186
+ return { patched: false };
187
+ }
188
+
189
+ const body = await readStdin();
190
+
191
+ const structural = checkNoteStructureFn({ body });
192
+ if (structural.rejected) {
193
+ stream.write(` Note not updated — ${structural.reason}\n`);
194
+ return { patched: false };
195
+ }
196
+
197
+ const scan = scanForSecretsFn({ title: '', tags: [], body });
198
+ if (scan.rejected) {
199
+ stream.write(` Note not updated — ${scan.reasons.join(' ')}\n`);
200
+ return { patched: false };
201
+ }
202
+ for (const warning of scan.warnings) {
203
+ stream.write(` Warning: ${warning}\n`);
204
+ }
205
+
206
+ const ticketKeys = ticketKey ? [ticketKey] : [];
207
+ const expectMtimeArg = parseFlag(cmdArgs, 'expect-mtime');
208
+ const expectedMtimeMs = expectMtimeArg !== undefined ? Number(expectMtimeArg) : undefined;
209
+ const { patched } = patchNoteBodyFn({ id, ticketKeys, body, expectedMtimeMs }, { configDir });
210
+ stream.write(patched ? ` Updated note (${id})\n` : ` Note not updated — (${id}) not found or already changed.\n`);
211
+ return { patched };
212
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Deterministic, dependency-free structural gate for a Recall note's body —
3
+ * catches empty, placeholder, or too-thin content before it's ever written
4
+ * to the vault. Separate concern from secret-scanner.mjs: this never judges
5
+ * sensitivity, only whether there's real content here at all.
6
+ */
7
+
8
+ const MIN_BODY_LENGTH = 10;
9
+
10
+ const PLACEHOLDER_BODIES = new Set([
11
+ 'todo', 'test', 'n/a', 'na', 'tbd', 'wip', 'placeholder',
12
+ 'xxx', 'asdf', 'fixme', 'fix me', '.', '-',
13
+ ]);
14
+
15
+ /**
16
+ * @param {{ body?: string }} note
17
+ * @returns {{ rejected: boolean, reason: string|null }}
18
+ */
19
+ export function checkNoteStructure({ body = '' } = {}) {
20
+ const trimmed = body.trim();
21
+
22
+ if (trimmed.length === 0) {
23
+ return { rejected: true, reason: 'Note body is empty.' };
24
+ }
25
+ if (PLACEHOLDER_BODIES.has(trimmed.toLowerCase())) {
26
+ return { rejected: true, reason: `Note body "${trimmed}" looks like a placeholder, not real content.` };
27
+ }
28
+ if (trimmed.length < MIN_BODY_LENGTH) {
29
+ return { rejected: true, reason: `Note body is too short to be useful (minimum ${MIN_BODY_LENGTH} characters).` };
30
+ }
31
+
32
+ return { rejected: false, reason: null };
33
+ }
@@ -12,6 +12,7 @@ import { isLicensed, showUpgradePrompt } from './license.mjs';
12
12
  import { listNotes } from './recall-vault.mjs';
13
13
  import { readCliToken } from './cli-auth.mjs';
14
14
  import { pullNotes } from './recall-sync.mjs';
15
+ import { maybeAutoFlush, flushQueue, readQueue } from './recall-queue.mjs';
15
16
  import { styleRecallResults } from './styled-assembler.mjs';
16
17
 
17
18
  /**
@@ -26,6 +27,7 @@ export async function runRecall(cmdArgs, {
26
27
  listNotesFn = listNotes,
27
28
  readCliTokenFn = readCliToken,
28
29
  pullNotesFn = pullNotes,
30
+ maybeAutoFlushFn = maybeAutoFlush,
29
31
  } = {}) {
30
32
  if (!isLicensedFn('pro', configDir)) {
31
33
  showUpgradePrompt('pro', 'ticketlens recall', { stream: errorStream });
@@ -45,6 +47,7 @@ export async function runRecall(cmdArgs, {
45
47
  configDir,
46
48
  ...(cmdArgs.includes('--no-cache') && { ttlMs: 0 }),
47
49
  });
50
+ await maybeAutoFlushFn({ cliToken, configDir });
48
51
  }
49
52
 
50
53
  const filter = TICKET_KEY_PATTERN.test(arg) ? { ticketKey: arg } : { query: arg };
@@ -55,3 +58,41 @@ export async function runRecall(cmdArgs, {
55
58
  stream.write(styleRecallResults(results, { styled, full }) + '\n');
56
59
  return { ok: true };
57
60
  }
61
+
62
+ /**
63
+ * Implements `tl recall sync` — manually flushes the local retry queue
64
+ * (notes whose team push previously failed for a transient reason). Unlike
65
+ * the auto-flush attempted from `runRecall`/`note add`, this is an explicit
66
+ * user action, so failures are reported visibly rather than staying silent.
67
+ *
68
+ * @param {string[]} cmdArgs
69
+ * @returns {Promise<{ ok: boolean }>}
70
+ */
71
+ export async function runRecallSync(cmdArgs, {
72
+ configDir = DEFAULT_CONFIG_DIR,
73
+ stream = process.stdout,
74
+ isLicensedFn = isLicensed,
75
+ readCliTokenFn = readCliToken,
76
+ readQueueFn = readQueue,
77
+ flushQueueFn = flushQueue,
78
+ } = {}) {
79
+ if (!isLicensedFn('pro', configDir)) {
80
+ showUpgradePrompt('pro', 'ticketlens recall', { stream });
81
+ return { ok: false };
82
+ }
83
+
84
+ const cliToken = readCliTokenFn(configDir);
85
+ if (!cliToken) {
86
+ stream.write('Not logged in — run `ticketlens login` first.\n');
87
+ return { ok: false };
88
+ }
89
+
90
+ if (readQueueFn(configDir).length === 0) {
91
+ stream.write('Nothing to sync.\n');
92
+ return { ok: true };
93
+ }
94
+
95
+ const { flushed, remaining } = await flushQueueFn({ cliToken, configDir, warn: (s) => stream.write(s) });
96
+ stream.write(`Synced ${flushed} note(s). ${remaining} still pending.\n`);
97
+ return { ok: true };
98
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Local retry queue for Recall notes whose push to the team backend failed
3
+ * for a transient reason (network error, timeout, or 5xx). A note is never
4
+ * lost — it's already safe in the local vault (recall-vault.mjs) before this
5
+ * module ever sees it; this only tracks the separate, best-effort intent to
6
+ * also sync it to the team.
7
+ *
8
+ * Retry classification happens once, at the moment the original push fails
9
+ * (isRetryableFailure) — 401/403/other-4xx are deliberately excluded: a
10
+ * stale session or a doomed payload will never succeed by retrying, and
11
+ * pushNote already warns the user about those synchronously.
12
+ *
13
+ * Growth is bounded two independent ways: a hard cap on entry count, and an
14
+ * age-based expiry keyed off firstQueuedAt (not failedAt, which refreshes on
15
+ * every retry attempt and would otherwise let a perpetually-failing entry
16
+ * live forever).
17
+ *
18
+ * enqueueNote/flushQueue do read-modify-write on the queue file with no file
19
+ * lock — same tradeoff already accepted by recall-pull-state.json and
20
+ * recall-entitlement-state.json in recall-sync.mjs. Two concurrent CLI
21
+ * invocations racing this file can lose one writer's update. Bounded,
22
+ * accepted risk: this queue is a retry-intent cache, not the source of truth
23
+ * (the note itself is already safe in the vault before it ever reaches here)
24
+ * — not worth a lock for a low-frequency, single-user CLI tool.
25
+ */
26
+
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
30
+ import { writeFileAtomically } from './recall-vault.mjs';
31
+ import { pushNote, hashToken } from './recall-sync.mjs';
32
+
33
+ const QUEUE_FILE = 'recall-pending.json';
34
+ const FLUSH_STATE_FILE = 'recall-flush-state.json';
35
+
36
+ export const MAX_QUEUE_SIZE = 200;
37
+ export const MAX_ENTRY_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
38
+ export const AUTO_FLUSH_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
39
+
40
+ function queuePath(configDir) {
41
+ return path.join(configDir, QUEUE_FILE);
42
+ }
43
+
44
+ function flushStatePath(configDir) {
45
+ return path.join(configDir, FLUSH_STATE_FILE);
46
+ }
47
+
48
+ /**
49
+ * @param {string} configDir
50
+ * @returns {Array<{id: string, notePayload: object, tokenHash: string, firstQueuedAt: string, failedAt: string, attempts: number}>}
51
+ */
52
+ export function readQueue(configDir) {
53
+ try {
54
+ const parsed = JSON.parse(fs.readFileSync(queuePath(configDir), 'utf8'));
55
+ return Array.isArray(parsed) ? parsed : [];
56
+ } catch {
57
+ return [];
58
+ }
59
+ }
60
+
61
+ function writeQueue(configDir, entries) {
62
+ writeFileAtomically(queuePath(configDir), JSON.stringify(entries));
63
+ }
64
+
65
+ function purgeExpired(entries, now) {
66
+ return entries.filter(entry => now - new Date(entry.firstQueuedAt).getTime() <= MAX_ENTRY_AGE_MS);
67
+ }
68
+
69
+ /**
70
+ * Decides whether a failed push is worth retrying later. Network errors and
71
+ * timeouts (pushNote returns no `status` for these) and 5xx responses are
72
+ * transient. 401 (session expired), 403 (not entitled / no team), and any
73
+ * other 4xx (e.g. a validation failure) are not — retrying cannot fix them
74
+ * without the user acting, or would just retry a payload that will never be
75
+ * accepted.
76
+ *
77
+ * @param {{ ok: boolean, status?: number }} result
78
+ * @returns {boolean}
79
+ */
80
+ export function isRetryableFailure(result) {
81
+ return !result.ok && (result.status === undefined || result.status >= 500);
82
+ }
83
+
84
+ /**
85
+ * Queues a note for later retry after a transient push failure. Purges
86
+ * expired entries first, then evicts the oldest entry (with a single warn)
87
+ * if appending would exceed MAX_QUEUE_SIZE.
88
+ *
89
+ * @param {object} notePayload - exact wire payload passed to pushNote
90
+ * @param {object} opts
91
+ * @param {string} opts.cliToken
92
+ * @param {string} [opts.configDir]
93
+ * @param {() => number} [opts.now]
94
+ * @param {Function} [opts.warn]
95
+ */
96
+ export function enqueueNote(notePayload, {
97
+ cliToken,
98
+ configDir = DEFAULT_CONFIG_DIR,
99
+ now = () => Date.now(),
100
+ warn = (s) => process.stderr.write(s),
101
+ } = {}) {
102
+ const nowMs = now();
103
+ let entries = purgeExpired(readQueue(configDir), nowMs);
104
+
105
+ if (entries.length >= MAX_QUEUE_SIZE) {
106
+ entries = entries.slice(1);
107
+ warn(' Recall queue full — dropped the oldest queued note to make room.\n');
108
+ }
109
+
110
+ const nowIso = new Date(nowMs).toISOString();
111
+ entries.push({
112
+ id: notePayload.external_id,
113
+ notePayload,
114
+ tokenHash: hashToken(cliToken),
115
+ firstQueuedAt: nowIso,
116
+ failedAt: nowIso,
117
+ attempts: 0,
118
+ });
119
+
120
+ writeQueue(configDir, entries);
121
+ }
122
+
123
+ /**
124
+ * Attempts to push every queued entry belonging to the current account
125
+ * (matched by tokenHash). Entries queued under a different account are left
126
+ * untouched — never attempted, never evicted by this pass. Expired entries
127
+ * are purged first, regardless of tokenHash.
128
+ *
129
+ * @param {object} opts
130
+ * @param {string} opts.cliToken
131
+ * @param {string} [opts.configDir]
132
+ * @param {Function} [opts.pushNoteFn]
133
+ * @param {Function} [opts.warn] - defaults to silent; callers doing a visible/manual sync should pass a real one
134
+ * @param {() => number} [opts.now]
135
+ * @returns {Promise<{ flushed: number, remaining: number }>}
136
+ */
137
+ export async function flushQueue({
138
+ cliToken,
139
+ configDir = DEFAULT_CONFIG_DIR,
140
+ pushNoteFn = pushNote,
141
+ isRetryableFailureFn = isRetryableFailure,
142
+ warn = () => {},
143
+ now = () => Date.now(),
144
+ } = {}) {
145
+ const nowMs = now();
146
+ const currentHash = hashToken(cliToken);
147
+ const entries = purgeExpired(readQueue(configDir), nowMs);
148
+
149
+ let flushed = 0;
150
+ const remaining = [];
151
+ for (const entry of entries) {
152
+ if (entry.tokenHash !== currentHash) {
153
+ remaining.push(entry);
154
+ continue;
155
+ }
156
+
157
+ const result = await pushNoteFn(entry.notePayload, { cliToken, configDir, warn });
158
+ if (result.ok) {
159
+ flushed++;
160
+ continue;
161
+ }
162
+
163
+ // A retry can surface a DIFFERENT failure than the one that originally
164
+ // queued this entry (e.g. the session expired between enqueue and this
165
+ // attempt) — reclassify every time rather than trusting the original
166
+ // enqueue decision, so a now-unrecoverable entry is dropped immediately
167
+ // instead of silently retrying for up to MAX_ENTRY_AGE_MS.
168
+ if (!isRetryableFailureFn(result)) continue;
169
+
170
+ remaining.push({ ...entry, attempts: entry.attempts + 1, failedAt: new Date(nowMs).toISOString() });
171
+ }
172
+
173
+ writeQueue(configDir, remaining);
174
+ return { flushed, remaining: remaining.length };
175
+ }
176
+
177
+ function readLastFlushAttemptAt(configDir) {
178
+ try {
179
+ return JSON.parse(fs.readFileSync(flushStatePath(configDir), 'utf8')).lastAttemptAt ?? null;
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ function writeLastFlushAttemptAt(configDir, isoTimestamp) {
186
+ try {
187
+ fs.writeFileSync(flushStatePath(configDir), JSON.stringify({ lastAttemptAt: isoTimestamp }), 'utf8');
188
+ } catch {
189
+ // Non-fatal — worst case the next command re-checks a moment sooner than the interval intends.
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Time-gated background flush, attempted from the CLI's existing
195
+ * Recall-touching entry points (note add's push, recall's pull) rather than
196
+ * on every invocation — a no-op unless the queue is non-empty AND at least
197
+ * AUTO_FLUSH_INTERVAL_MS has passed since the last attempt. The attempt
198
+ * timestamp is recorded even on failure, so a down backend can't be hammered
199
+ * once per command within the window.
200
+ *
201
+ * @param {object} opts
202
+ * @param {string} opts.cliToken
203
+ * @param {string} [opts.configDir]
204
+ * @param {() => number} [opts.now]
205
+ * @param {Function} [opts.flushQueueFn]
206
+ */
207
+ export async function maybeAutoFlush({
208
+ cliToken,
209
+ configDir = DEFAULT_CONFIG_DIR,
210
+ now = () => Date.now(),
211
+ flushQueueFn = flushQueue,
212
+ } = {}) {
213
+ if (readQueue(configDir).length === 0) return;
214
+
215
+ const lastAttemptAt = readLastFlushAttemptAt(configDir);
216
+ const nowMs = now();
217
+ if (lastAttemptAt && nowMs - new Date(lastAttemptAt).getTime() < AUTO_FLUSH_INTERVAL_MS) return;
218
+
219
+ try {
220
+ await flushQueueFn({ cliToken, configDir, now });
221
+ } catch {
222
+ // A down backend or a thrown network error must never crash the command
223
+ // that opportunistically triggered this background attempt.
224
+ } finally {
225
+ writeLastFlushAttemptAt(configDir, new Date(nowMs).toISOString());
226
+ }
227
+ }
@@ -56,7 +56,7 @@ function entitlementCachePath(configDir) {
56
56
  // switch (e.g. login as someone else) so a stale cache never suppresses a
57
57
  // different account's push. Found via Local Live Test: configDir alone is
58
58
  // not a valid cache key — cli-token.json can change without configDir changing.
59
- function hashToken(cliToken) {
59
+ export function hashToken(cliToken) {
60
60
  return createHash('sha256').update(cliToken).digest('hex');
61
61
  }
62
62
 
@@ -107,7 +107,11 @@ export async function pushNote(note, {
107
107
  // flips the Recall grant — without this, the warning below repeats forever.
108
108
  const checkedAt = readEntitlementCheckedAt(configDir, cliToken);
109
109
  if (checkedAt && now() - new Date(checkedAt).getTime() < ttlMs) {
110
- return { ok: false, skipped: true };
110
+ // status: 403 — this is a cached replay of the entitlement 403 below, not a
111
+ // fresh network outcome. Without it, isRetryableFailure's "no status means
112
+ // network error" rule misclassifies this as transient and queues a note
113
+ // that can never succeed until the owner grants entitlement.
114
+ return { ok: false, status: 403, skipped: true };
111
115
  }
112
116
 
113
117
  warnIfInsecure(apiBase(), warn);
@@ -49,7 +49,7 @@ function generateNoteId() {
49
49
  return `${Date.now()}-${randomBytes(3).toString('hex')}.md`;
50
50
  }
51
51
 
52
- function writeFileAtomically(filePath, contents) {
52
+ export function writeFileAtomically(filePath, contents) {
53
53
  const tmpPath = `${filePath}.${process.pid}.tmp`;
54
54
  fs.writeFileSync(tmpPath, contents, 'utf8');
55
55
  fs.renameSync(tmpPath, filePath);
@@ -164,6 +164,59 @@ export function deleteNote({ external_id: externalId, tickets = [] }, { configDi
164
164
  return { deleted: true, prefix };
165
165
  }
166
166
 
167
+ /**
168
+ * Overwrites an existing local note's body in place — used by the jtb skill's
169
+ * generator/validator quality loop to swap in a better draft after `note add`
170
+ * already saved the original. Patch-only: never creates a note, and every
171
+ * frontmatter field except body is carried over unchanged, so this can never
172
+ * become a covert way to retitle/retag/re-tie a note to a different ticket.
173
+ *
174
+ * Guards, same failure-mode split as deleteNote: a malformed id or ticket key
175
+ * is a caller bug (throw); a missing file, an externalId that doesn't match
176
+ * what's on disk, or a body that changed since the caller last observed it
177
+ * (expectedMtimeMs) are all best-effort no-ops, not errors — the original
178
+ * note is always left exactly as-is on any of these.
179
+ *
180
+ * @param {{ id: string, ticketKeys?: string[], body: string, expectedMtimeMs?: number }} params
181
+ * @param {{ configDir?: string }} [opts]
182
+ * @returns {{ patched: boolean, path: string|null }}
183
+ */
184
+ export function patchNoteBody({ id, ticketKeys = [], body, expectedMtimeMs }, { configDir = DEFAULT_CONFIG_DIR } = {}) {
185
+ if (!EXTERNAL_ID_PATTERN.test(id)) {
186
+ throw new Error(`Invalid note id: "${id}"`);
187
+ }
188
+
189
+ const prefix = resolvePrefix(ticketKeys[0]);
190
+ const notePath = path.join(prefixDir(configDir, prefix), id);
191
+
192
+ if (!fs.existsSync(notePath)) {
193
+ return { patched: false, path: null };
194
+ }
195
+ if (expectedMtimeMs !== undefined && fs.statSync(notePath).mtimeMs !== expectedMtimeMs) {
196
+ return { patched: false, path: notePath };
197
+ }
198
+
199
+ const existing = readNote(notePath);
200
+ if (!existing || existing.externalId !== id) {
201
+ return { patched: false, path: notePath };
202
+ }
203
+
204
+ const data = {
205
+ title: existing.title,
206
+ aliases: existing.aliases,
207
+ tickets: existing.tickets,
208
+ tags: existing.tags,
209
+ author: existing.author,
210
+ created: existing.created,
211
+ status: existing.status,
212
+ sources: existing.sources,
213
+ externalId: existing.externalId,
214
+ };
215
+
216
+ writeFileAtomically(notePath, serializeFrontmatter(data, body));
217
+ return { patched: true, path: notePath };
218
+ }
219
+
167
220
  /**
168
221
  * Reads one note file. Never trusts the file completely — a note can be
169
222
  * hand-edited (README documents them as plain markdown, readable in any