ticketlens 0.16.1 → 0.17.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,7 +394,9 @@ 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
 
@@ -610,18 +610,28 @@ 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.16.1",
3
+ "version": "0.17.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.17.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.
@@ -219,6 +219,27 @@ echo "The body text of the note, one or more paragraphs." | \
219
219
 
220
220
  To search saved notes directly (outside of automatic brief injection): `ticketlens recall "<query>"`.
221
221
 
222
+ ### Quality loop (Pro, in-session only)
223
+
224
+ 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.
225
+
226
+ When it does apply, run up to 3 rounds:
227
+
228
+ 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.
229
+ 2. **Generator** — a subagent drafts an improved body: concrete file/line references over vague prose, no invented facts not already established this session.
230
+ 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).
231
+ 4. If the draft scores as a genuine improvement, write it back:
232
+ ```bash
233
+ echo "The improved body text." | \
234
+ ticketlens note patch --id="THE-ID-PRINTED-ABOVE" --ticket=TICKET-KEY --expect-mtime="THE-MTIME-FROM-STEP-1"
235
+ ```
236
+ `--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.
237
+ 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.
238
+
239
+ 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.
240
+
241
+ **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.
242
+
222
243
  ### Privacy
223
244
  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.
224
245
 
@@ -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
  }
@@ -10,7 +10,8 @@ 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';
16
17
  import { incrementDraftKept, incrementDraftDeleted } from './activity-counter.mjs';
@@ -66,6 +67,7 @@ export async function runNoteAdd(cmdArgs, {
66
67
  stream = process.stderr,
67
68
  readStdin = defaultReadStdin,
68
69
  isLicensedFn = isLicensed,
70
+ checkNoteStructureFn = checkNoteStructure,
69
71
  scanForSecretsFn = scanForSecrets,
70
72
  writeNoteFn = writeNote,
71
73
  readCliTokenFn = readCliToken,
@@ -103,6 +105,13 @@ export async function runNoteAdd(cmdArgs, {
103
105
  body += gatherAttachmentExcerpts(configDir, ticketKey, listAttachmentsFn, extractTextFn);
104
106
  }
105
107
 
108
+ const structural = checkNoteStructureFn({ body });
109
+ if (structural.rejected) {
110
+ stream.write(` Note not saved — ${structural.reason}\n`);
111
+ incrementDraftDeletedFn(configDir);
112
+ return { written: false };
113
+ }
114
+
106
115
  const scan = scanForSecretsFn({ title, tags, body });
107
116
  if (scan.rejected) {
108
117
  stream.write(` Note not saved — ${scan.reasons.join(' ')}\n`);
@@ -132,3 +141,65 @@ export async function runNoteAdd(cmdArgs, {
132
141
 
133
142
  return { written: true };
134
143
  }
144
+
145
+ /**
146
+ * Implements `tl note patch` — overwrites an existing note's body with a
147
+ * better draft. Internal plumbing for the jtb skill's quality loop (never
148
+ * meant to be typed by hand): the loop's generator subagent produces a body,
149
+ * SKILL.md's orchestration pipes it through this command, and the new body
150
+ * gets exactly the same structural and secret gates a user-typed body gets —
151
+ * there is no weaker path here for AI-authored content.
152
+ *
153
+ * @param {string[]} cmdArgs
154
+ * @returns {Promise<{ patched: boolean }>}
155
+ */
156
+ export async function runNotePatch(cmdArgs, {
157
+ configDir = DEFAULT_CONFIG_DIR,
158
+ stream = process.stderr,
159
+ readStdin = defaultReadStdin,
160
+ isLicensedFn = isLicensed,
161
+ checkNoteStructureFn = checkNoteStructure,
162
+ scanForSecretsFn = scanForSecrets,
163
+ patchNoteBodyFn = patchNoteBody,
164
+ } = {}) {
165
+ if (!isLicensedFn('pro', configDir)) {
166
+ showUpgradePrompt('pro', 'ticketlens note', { stream });
167
+ return { patched: false };
168
+ }
169
+
170
+ const id = parseFlag(cmdArgs, 'id');
171
+ if (!id) {
172
+ stream.write('Usage: ticketlens note patch --id="..." [--ticket=KEY]\n');
173
+ return { patched: false };
174
+ }
175
+
176
+ const ticketKey = parseFlag(cmdArgs, 'ticket');
177
+ if (ticketKey && !TICKET_KEY_PATTERN.test(ticketKey)) {
178
+ stream.write(` Invalid --ticket value "${ticketKey}" — expected a ticket key like PROJ-123.\n`);
179
+ return { patched: false };
180
+ }
181
+
182
+ const body = await readStdin();
183
+
184
+ const structural = checkNoteStructureFn({ body });
185
+ if (structural.rejected) {
186
+ stream.write(` Note not updated — ${structural.reason}\n`);
187
+ return { patched: false };
188
+ }
189
+
190
+ const scan = scanForSecretsFn({ title: '', tags: [], body });
191
+ if (scan.rejected) {
192
+ stream.write(` Note not updated — ${scan.reasons.join(' ')}\n`);
193
+ return { patched: false };
194
+ }
195
+ for (const warning of scan.warnings) {
196
+ stream.write(` Warning: ${warning}\n`);
197
+ }
198
+
199
+ const ticketKeys = ticketKey ? [ticketKey] : [];
200
+ const expectMtimeArg = parseFlag(cmdArgs, 'expect-mtime');
201
+ const expectedMtimeMs = expectMtimeArg !== undefined ? Number(expectMtimeArg) : undefined;
202
+ const { patched } = patchNoteBodyFn({ id, ticketKeys, body, expectedMtimeMs }, { configDir });
203
+ stream.write(patched ? ` Updated note (${id})\n` : ` Note not updated — (${id}) not found or already changed.\n`);
204
+ return { patched };
205
+ }
@@ -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
+ }
@@ -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