ticketlens 0.21.3 → 0.21.5

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.
@@ -630,6 +630,16 @@ switch (command) {
630
630
  });
631
631
  break;
632
632
  }
633
+ if (cmdArgs[0] === 'delete') {
634
+ const { runNoteDelete } = await import('../skills/jtb/scripts/lib/note-command.mjs');
635
+ runNoteDelete(cmdArgs.slice(1)).then(({ deleted }) => {
636
+ if (!deleted) process.exitCode = 1;
637
+ }).catch(err => {
638
+ process.stderr.write(`Error: ${err.message}\n`);
639
+ process.exitCode = 1;
640
+ });
641
+ break;
642
+ }
633
643
  process.stderr.write('Usage: ticketlens note add --title="..." [--ticket=KEY] [--tags=a,b]\n');
634
644
  process.exitCode = 1;
635
645
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.21.3",
3
+ "version": "0.21.5",
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": {
@@ -214,7 +214,7 @@ Immediately before dispatching the call, say one line in your response: `🔖 Re
214
214
 
215
215
  ### How to dispatch the call
216
216
 
217
- If your environment lets you dispatch a tool call without waiting for its result — a background task, an async subagent, a fire-and-forget job — use that mechanism for this call, so it doesn't block your primary work while the note syncs to the team. If you have no such mechanism, call it synchronously; it's fast, and it never blocks or fails your session either way, so there's no harm in the fallback.
217
+ If your environment lets you dispatch a tool call without waiting for its result — a background task, an async subagent, a fire-and-forget job — try it **once**. If it errors for any reason, don't retry the background path — fall back to calling it synchronously immediately. It's fast and never blocks or fails your session either way, so the synchronous fallback costs nothing; repeatedly retrying a broken background mechanism only adds visible noise for no benefit.
218
218
 
219
219
  ```bash
220
220
  echo "The body text of the note, one or more paragraphs." | \
@@ -520,6 +520,12 @@ export function printNoteHelp({ stream = process.stdout } = {}) {
520
520
  ` Code session — not typically invoked by hand. Every note it writes gets the`,
521
521
  ` same structural and secret-scan checks ${s.brand('note add')} applies to user input.`,
522
522
  '',
523
+ ` ${s.bold('ticketlens note delete')} ${s.dim('--id="..." [--ticket=KEY]')} ${s.dim('[Pro]')}`,
524
+ '',
525
+ ` Removes a note from your local vault. Local only — if this note was already`,
526
+ ` pushed to a team, teammates who pulled it keep their copy; deleting it there`,
527
+ ` too is a manager action from the Console (Admin > Recall).`,
528
+ '',
523
529
  ];
524
530
  stream.write(lines.join('\n') + '\n');
525
531
  }
@@ -11,7 +11,7 @@ import { DEFAULT_CONFIG_DIR } from './config.mjs';
11
11
  import { isLicensed, showUpgradePrompt } from './license.mjs';
12
12
  import { scanForSecrets } from './secret-scanner.mjs';
13
13
  import { checkNoteStructure } from './note-structural-check.mjs';
14
- import { writeNote, patchNoteBody } from './recall-vault.mjs';
14
+ import { writeNote, patchNoteBody, deleteNote } from './recall-vault.mjs';
15
15
  import { readCliToken } from './cli-auth.mjs';
16
16
  import { pushNote } from './recall-sync.mjs';
17
17
  import { enqueueNote, isRetryableFailure, maybeAutoFlush } from './recall-queue.mjs';
@@ -210,3 +210,45 @@ export async function runNotePatch(cmdArgs, {
210
210
  stream.write(patched ? ` Updated note (${id})\n` : ` Note not updated — (${id}) not found or already changed.\n`);
211
211
  return { patched };
212
212
  }
213
+
214
+ /**
215
+ * Implements `tl note delete` — removes a note from the local vault only.
216
+ *
217
+ * Local-only, same limitation `note patch` already documents: if this note
218
+ * was already pushed to a team (Team Recall enabled), teammates who already
219
+ * pulled it keep their copy — this does not push a tombstone. Deleting a
220
+ * note everywhere it's synced is a manager action from the Console today
221
+ * (Admin > Recall), which does propagate a tombstone on next pull.
222
+ *
223
+ * @param {string[]} cmdArgs
224
+ * @returns {Promise<{ deleted: boolean }>}
225
+ */
226
+ export async function runNoteDelete(cmdArgs, {
227
+ configDir = DEFAULT_CONFIG_DIR,
228
+ stream = process.stderr,
229
+ isLicensedFn = isLicensed,
230
+ deleteNoteFn = deleteNote,
231
+ } = {}) {
232
+ if (!isLicensedFn('pro', configDir)) {
233
+ showUpgradePrompt('pro', 'ticketlens note', { stream });
234
+ return { deleted: false };
235
+ }
236
+
237
+ const id = parseFlag(cmdArgs, 'id');
238
+ if (!id) {
239
+ stream.write('Usage: ticketlens note delete --id="..." [--ticket=KEY]\n');
240
+ return { deleted: false };
241
+ }
242
+
243
+ const ticketKey = parseFlag(cmdArgs, 'ticket');
244
+ if (ticketKey && !TICKET_KEY_PATTERN.test(ticketKey)) {
245
+ stream.write(` Invalid --ticket value "${ticketKey}" — expected a ticket key like PROJ-123.\n`);
246
+ return { deleted: false };
247
+ }
248
+
249
+ const { deleted } = deleteNoteFn({ external_id: id, tickets: ticketKey ? [ticketKey] : [] }, { configDir });
250
+ stream.write(deleted
251
+ ? ` Deleted note (${id}) — local vault only; see help for team-synced notes.\n`
252
+ : ` Note not deleted — (${id}) not found.\n`);
253
+ return { deleted };
254
+ }
@@ -101,25 +101,41 @@ function hasInternalCaseSwitch(token) {
101
101
  /**
102
102
  * True for a token that stops a joined-chunk run: either a recognized git/
103
103
  * checksum label word ("commit", "sha256", "md5sum", ...), or an ordinary
104
- * English word (letters only, no base64-style case switching). Anything else —
105
- * a fragment containing a digit/symbol, or an all-letter chunk that still reads
106
- * as random content — stays eligible to join, so a secret split by whitespace
107
- * can still be reassembled for the entropy check.
104
+ * English word (letters only, optionally with an internal possessive/
105
+ * contraction apostrophe — "relay's", "doesn't" — but no base64-style case
106
+ * switching). Anything else — a fragment containing a digit or other symbol,
107
+ * a hyphenated compound, or an all-letter chunk that still reads as random
108
+ * content — stays eligible to join, so a secret split by whitespace can still
109
+ * be reassembled for the entropy check.
108
110
  *
109
- * Known accepted gap: this can't tell a genuine dictionary word from one an
111
+ * The apostrophe allowance matters because without it, an ordinary possessive
112
+ * next to another non-label token (e.g. a hyphenated compound: "relay's
113
+ * decision-lookup") never gets a chance to stop the run — both fail to
114
+ * qualify, so they concatenate into one artificial blob whose mixed
115
+ * punctuation trips the entropy threshold. A real false positive, not a
116
+ * hypothetical one (see the regression test below). Hyphenated tokens are
117
+ * deliberately NOT given the same allowance: a short prefix + hyphen + long
118
+ * letter-run is indistinguishable from a real compound word by shape alone
119
+ * (e.g. "sk-abcdefghijklmnop", the letters-only half of a whitespace-split
120
+ * API key) — see the adjacent regression test for exactly this shape. Once
121
+ * the apostrophe fix lets "relay's" stop the run on its own, an adjacent
122
+ * hyphenated compound is short enough on its own to fall under
123
+ * MIN_RANDOM_TOKEN_LENGTH anyway, so it never needed the same allowance.
124
+ *
125
+ * Known accepted gap: this can't tell a genuine possessive from one an
110
126
  * attacker deliberately chose as a separator to defeat the scanner (e.g.
111
- * splitting a secret around the word "wall"). Closing that would require
112
- * treating ordinary prose words as joinable too, which in turn re-joins real
113
- * sentence text into false-positive "random" strings (verified empirically —
114
- * see the regression test for "PROD-123456 for background"). Entropy-based
115
- * detection is a safety net, not a cryptographic guarantee; the well-known
116
- * secret shapes in HARD_REJECT_PATTERNS are the layer that's fully whitespace-
117
- * and word-insertion-proof, since they match a specific literal prefix.
127
+ * splitting a secret around "user's"). Closing that would require treating
128
+ * ordinary prose words as joinable too, which in turn re-joins real sentence
129
+ * text into false-positive "random" strings (verified empirically — see the
130
+ * regression test for "PROD-123456 for background"). Entropy-based detection
131
+ * is a safety net, not a cryptographic guarantee; the well-known secret
132
+ * shapes in HARD_REJECT_PATTERNS are the layer that's fully whitespace- and
133
+ * word-insertion-proof, since they match a specific literal prefix.
118
134
  */
119
135
  function isLabelWord(token) {
120
136
  const stripped = stripEdgePunctuation(token);
121
137
  if (GIT_REFERENCE_WORD_RE.test(stripped)) return true;
122
- return /^[A-Za-z]+$/.test(stripped) && !hasInternalCaseSwitch(stripped);
138
+ return /^[A-Za-z]+(?:'[A-Za-z]+)*$/.test(stripped) && !hasInternalCaseSwitch(stripped);
123
139
  }
124
140
 
125
141
  /**