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 +3 -1
- package/bin/ticketlens.mjs +20 -10
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +22 -1
- package/skills/jtb/scripts/lib/help.mjs +7 -0
- package/skills/jtb/scripts/lib/note-command.mjs +72 -1
- package/skills/jtb/scripts/lib/note-structural-check.mjs +33 -0
- package/skills/jtb/scripts/lib/recall-vault.mjs +53 -0
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
|
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -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]
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
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 {
|
|
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
|