ticketlens 0.38.44 → 0.38.46
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 +4 -2
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +5 -3
- package/skills/jtb/hooks/recall-nudge-stop.mjs +10 -5
- package/skills/jtb/scripts/lib/api-utils.mjs +1 -1
- package/skills/jtb/scripts/lib/help.mjs +1 -1
- package/skills/jtb/scripts/lib/mcp-tool-schemas.mjs +3 -3
- package/skills/jtb/scripts/lib/note-command.mjs +30 -4
- package/skills/jtb/scripts/lib/note-structural-check.mjs +36 -0
- package/skills/jtb/scripts/lib/profile-resolver.mjs +26 -3
- package/skills/jtb/scripts/lib/recall-command.mjs +14 -1
- package/skills/jtb/scripts/lib/recall-settings-sync.mjs +9 -0
- package/skills/jtb/scripts/lib/recall-strictness.mjs +9 -0
package/README.md
CHANGED
|
@@ -452,6 +452,8 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
452
452
|
|
|
453
453
|
**Queue settings:** the retry cooldown (default 15 min), per-request timeout (4s), max queued notes (200), and queued-note expiry (30 days) are set by your team manager at `console/admin/recall` and apply to every member's CLI — solo users get the same platform defaults. `ticketlens recall settings` shows the values currently in effect, fetched live: a manager's change is visible the moment your CLI's next retry decision runs, not on a delay.
|
|
454
454
|
|
|
455
|
+
**Recall capture strictness:** your team manager can also set a team default at `console/admin/recall` (Settings tab). It only applies if you've never run `ticketlens config set recallStrictness` yourself — your own explicit choice always wins over the team default. `ticketlens recall settings` also reports the strictness currently in effect and where it came from.
|
|
456
|
+
|
|
455
457
|
**Removing a note:** `ticketlens note delete --id="..." [--ticket=KEY]` removes a note from your local vault. Local only — if it was already pushed to a team, teammates who pulled it keep their copy; deleting it there too is a manager action from the Console (Admin > Recall).
|
|
456
458
|
|
|
457
459
|
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `fetch`, `triage`, `compliance`, `review`, `standup`, `pr`, `stats`, `issue_types`, `history`, `collisions`, `ledger`, `doctor`, `recall_add`, `recall_update`, `recall_delete`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `ticket_create` as native tools — every CLI action now has an MCP tool, so any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same license gate per tool, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. `fetch`, `doctor`, and `standup` are Free; `triage`'s base scan is Free with some options gated Pro/Team, same as the CLI (`ticketlens triage --help`); `compliance` and `pr` are Free, sharing a 3-checks/month cap on their requirements-coverage section, Pro unlimited; `review` is Free for branch/files/ticket context, with its coverage/focus section requiring Pro as a plain license check — it does not draw from that same monthly counter; `stats` is Free with a 7-day lookback cap, Pro extends it to 30 days, same split as the CLI (`ticketlens stats --help`); `issue_types` is Free and Jira-only — pre-fetches and caches a profile's real creatable projects and issue types ahead of a `ticket_create` attempt, sharing its cache with that tool's own reactive enrichment; Linear/GitHub profiles get a clear "not available" instead of an empty result; `history` reads local triage history only (zero network) and requires Pro; `collisions` requires `ticketlens login` (Console access) plus a Team license; `ledger` exports the local, signed compliance audit trail (zero network) and requires Pro; every other tool needs Pro. `recall_update` overwrites an existing Recall note's body — internal plumbing for the note quality loop, not typically called directly. `recall_delete` is destructive and local-vault-only — requires `confirm: true` alongside `id` to actually execute; there is no interactive y/N prompt under MCP (no real terminal to prompt against), so omitting it always fails rather than silently blocking. Point your harness's MCP config at it: `{ "command": "ticketlens", "args": ["mcp"] }` — or run `ticketlens mcp install` in a project to write that entry into its `.mcp.json` for you (creates the file if it doesn't exist, merges in if it does — never touches any other entry already there; `--dry-run` to preview first).
|
|
@@ -808,7 +810,7 @@ ticketlens note delete --id="..." --ticket=CNV1-2 # Remove a note from your loc
|
|
|
808
810
|
ticketlens recall CNV1-2 # Search saved notes by ticket key [Pro]
|
|
809
811
|
ticketlens recall "retry backoff" # Free-text search across all notes [Pro]
|
|
810
812
|
ticketlens recall sync # Retry any notes stuck in the local queue [Team+]
|
|
811
|
-
ticketlens recall settings # Show effective retry-queue settings, fetched live [Team+]
|
|
813
|
+
ticketlens recall settings # Show effective retry-queue settings + capture strictness, fetched live [Team+]
|
|
812
814
|
ticketlens mcp # Start the MCP stdio server (recall/ticket write tools) [Pro]
|
|
813
815
|
ticketlens mcp install # Register it into the current project's .mcp.json
|
|
814
816
|
ticketlens mcp install --dry-run # Preview the registration without writing
|
|
@@ -920,7 +922,7 @@ ticketlens note add --title="..." # Save a Recall note (body from stdin)
|
|
|
920
922
|
ticketlens note delete --id="..." # Remove a note from your local vault
|
|
921
923
|
ticketlens recall <query|TICKET-KEY> # Search your saved Recall notes
|
|
922
924
|
ticketlens recall sync # Retry any notes stuck in the local queue
|
|
923
|
-
ticketlens recall settings # Show effective retry-queue settings, fetched live
|
|
925
|
+
ticketlens recall settings # Show effective retry-queue settings + capture strictness, fetched live
|
|
924
926
|
ticketlens mcp # Start the MCP stdio server (recall/ticket write tools)
|
|
925
927
|
ticketlens comment CNV1-2 --body="..." # Post a comment to the tracker
|
|
926
928
|
ticketlens comment CNV1-2 --body="..." --attach=./bug.png # Attach local files (comment/create only)
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.42.
|
|
1
|
+
<!-- jtb-skill-version: 0.42.5 -->
|
|
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.
|
|
@@ -62,7 +62,7 @@ Fetches a Jira ticket and produces a structured brief with code references, then
|
|
|
62
62
|
/jtb note add --title="..." --attach=./shot.png,./log.txt # attach local files to a Recall note (Pro, local vault only — see note below)
|
|
63
63
|
/jtb recall PROD-1234 # search saved Recall notes (Pro)
|
|
64
64
|
/jtb recall sync # retry any notes stuck in the local queue (Team+)
|
|
65
|
-
/jtb recall settings # show effective retry-queue settings, fetched live (Team+)
|
|
65
|
+
/jtb recall settings # show effective retry-queue settings + Recall capture strictness, fetched live (Team+)
|
|
66
66
|
/jtb comment PROD-1234 --body="..." # post a comment to the tracker (Pro)
|
|
67
67
|
/jtb transition PROD-1234 # list the tracker's current valid transitions (Pro)
|
|
68
68
|
/jtb transition PROD-1234 --target="Done" --confirm # execute the transition (Pro)
|
|
@@ -356,6 +356,8 @@ Example that qualifies: an undocumented schema quirk found only by reading raw D
|
|
|
356
356
|
|
|
357
357
|
Do not capture: routine/obvious information, anything already fully covered by the ticket description or comments, or content shaped like a credential or API key — the tool rejects those outright at save time.
|
|
358
358
|
|
|
359
|
+
**Length:** keep both the title and body brief — a sharp one- or two-sentence takeaway, not a paragraph. The recommended max scales with the same strictness level as the section above: 20 words (strict), 30 words (balanced, default), 50 words (loose). Going over doesn't block the save — the CLI/MCP tool prints a non-blocking warning so you can tighten it, the same way it prints warnings for a detected email address or similar low-severity secret-scan hit.
|
|
360
|
+
|
|
359
361
|
Capture **at the moment you confirm the insight**, not deferred to the end of the session — don't ask the user first, just do it. It requires a Pro license; on a Free account it will no-op with an upgrade hint on stderr, which is fine to ignore. It never blocks or fails the session.
|
|
360
362
|
|
|
361
363
|
Immediately before dispatching the call, say one line in your response: `🔖 Recall-flag: <one-line reason>`. This is a fixed marker some harness setups scan for to confirm captures are actually happening — always emit it right when you decide to capture, even if you go on to call `note add` in the same breath.
|
|
@@ -365,7 +367,7 @@ Immediately before dispatching the call, say one line in your response: `🔖 Re
|
|
|
365
367
|
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.
|
|
366
368
|
|
|
367
369
|
```bash
|
|
368
|
-
echo "
|
|
370
|
+
echo "Retry client swallowed 429s silently; added backoff and a warning log on the third failure." | \
|
|
369
371
|
ticketlens note add --title="Short title" --ticket=TICKET-KEY --tags=a,b
|
|
370
372
|
```
|
|
371
373
|
|
|
@@ -26,9 +26,12 @@
|
|
|
26
26
|
* again after the next compaction/resume rollover, since that dismissal
|
|
27
27
|
* was never recorded anywhere — only a real capture was.
|
|
28
28
|
*
|
|
29
|
-
* Which of the two cases above actually blocks is governed by the
|
|
30
|
-
*
|
|
31
|
-
*
|
|
29
|
+
* Which of the two cases above actually blocks is governed by the effective
|
|
30
|
+
* recallStrictness — the active profile's own explicit config-set value, or
|
|
31
|
+
* else the team's Console-set default (backlog #20), resolved entirely from
|
|
32
|
+
* local state via resolveEffectiveRecallStrictness() so this hook never
|
|
33
|
+
* touches the network. See recall-nudge-lib.mjs's shouldNag() doc comment
|
|
34
|
+
* for the calibration and why strict doesn't widen this further.
|
|
32
35
|
*
|
|
33
36
|
* resolveProfile() below is given scanTranscript()'s matched ticket key (not
|
|
34
37
|
* null), so it resolves by ticket-key prefix the same way the brief-injection
|
|
@@ -40,7 +43,8 @@
|
|
|
40
43
|
*/
|
|
41
44
|
|
|
42
45
|
import { readStdinJson, readState, writeState, scanTranscript, hasRecentCapture, writeLastCaptureAt, hasRecentNag, writeLastNagAt, shouldNag } from './recall-nudge-lib.mjs';
|
|
43
|
-
import { resolveProfile,
|
|
46
|
+
import { resolveProfile, resolveEffectiveRecallStrictness } from '../scripts/lib/profile-resolver.mjs';
|
|
47
|
+
import { readCliToken } from '../scripts/lib/cli-auth.mjs';
|
|
44
48
|
|
|
45
49
|
const input = readStdinJson();
|
|
46
50
|
const sessionId = input?.session_id;
|
|
@@ -60,7 +64,8 @@ const state = readState(sessionId);
|
|
|
60
64
|
if (state.stopChecked) process.exit(0); // already asked once this session — respect the answer
|
|
61
65
|
|
|
62
66
|
const profile = resolveProfile(ticketKey, { cwd });
|
|
63
|
-
const
|
|
67
|
+
const cliToken = readCliToken();
|
|
68
|
+
const recallStrictness = resolveEffectiveRecallStrictness({ profile, cliToken });
|
|
64
69
|
|
|
65
70
|
if (!shouldNag({ sawFetch, sawRecallFlag, sawNoteAdd, recallStrictness })) {
|
|
66
71
|
process.exit(0); // nothing this strictness level requires a capture for
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Centralised here to avoid triplicating the regex and warning logic.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
export const DEFAULT_API_BASE = '
|
|
6
|
+
export const DEFAULT_API_BASE = 'https://api.ticketlens.app';
|
|
7
7
|
export const DEFAULT_SITE_BASE = 'https://ticketlens.app';
|
|
8
8
|
|
|
9
9
|
// Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
|
|
@@ -53,7 +53,7 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
53
53
|
` ${s.brand('ticketlens')} note delete ${s.dim('--id=... [--ticket=KEY]')} Remove a note from your local vault ${s.dim('[Pro]')}`,
|
|
54
54
|
` ${s.brand('ticketlens')} recall ${s.dim('<query|TICKET-KEY>')} Search your saved Recall notes ${s.dim('[Pro]')}`,
|
|
55
55
|
` ${s.brand('ticketlens')} recall sync Retry any notes stuck in the local queue ${s.dim('[Team+]')}`,
|
|
56
|
-
` ${s.brand('ticketlens')} recall settings Show effective retry-queue settings, fetched live ${s.dim('[Team+]')}`,
|
|
56
|
+
` ${s.brand('ticketlens')} recall settings Show effective retry-queue settings + capture strictness, fetched live ${s.dim('[Team+]')}`,
|
|
57
57
|
` ${s.brand('ticketlens')} mcp Start the MCP stdio server (Recall + ticket writes) ${s.dim('[Pro]')}`,
|
|
58
58
|
` ${s.brand('ticketlens')} comment ${s.dim('<TICKET-KEY> --body=... [--attach=...]')} Post a comment to the tracker ${s.dim('[Pro]')}`,
|
|
59
59
|
` ${s.brand('ticketlens')} transition ${s.dim('<TICKET-KEY> [--target=... --confirm]')} Move ticket status ${s.dim('[Pro]')}`,
|
|
@@ -161,10 +161,10 @@ export const TOOLS = [
|
|
|
161
161
|
inputSchema: {
|
|
162
162
|
type: 'object',
|
|
163
163
|
properties: {
|
|
164
|
-
title: { type: 'string', description: 'Short one-line title.' },
|
|
164
|
+
title: { type: 'string', description: 'Short one-line title, ~20 (strict), 30 (balanced), or 50 (loose) words max — scales with recallStrictness — going over doesn\'t block the save, but prints a non-blocking warning.' },
|
|
165
165
|
ticket: { type: 'string', description: 'Optional ticket key, e.g. PROJ-123.' },
|
|
166
166
|
tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags derived from this note\'s actual content — the specific technology, error type, root cause, or affected component (e.g. "retry-backoff", "null-pointer", "auth-middleware"). Never the project name or a generic category word like "gotcha" or "bug" — those provide no search signal to someone else looking for this note later. A tag that just restates the title in different words, or one you cannot trace to a specific sentence in the body, gives that same zero signal — if you cannot point to the exact phrase that justifies it, drop it.' },
|
|
167
|
-
body: { type: 'string', description: 'The note body —
|
|
167
|
+
body: { type: 'string', description: 'The note body — brief, not a paragraph. ~20 (strict), 30 (balanced), or 50 (loose) words max — scales with recallStrictness — going over doesn\'t block the save, but prints a non-blocking warning.' },
|
|
168
168
|
attachments: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach (screenshots, logs, etc.), saved next to the note in the local vault. Same 10MB/file, 50MB/call, 20-file caps as ticket_comment/ticket_create for the local save. If this account is entitled and the note syncs to a team, attachments sync too — visible and downloadable from Console > Admin > Recall — but the sync path has a lower 12MB/call cap than the local save (backend request-size limit). Going over it fails the WHOLE push (note text included), not just the attachment; the note stays saved locally either way.' },
|
|
169
169
|
},
|
|
170
170
|
required: ['title', 'body'],
|
|
@@ -177,7 +177,7 @@ export const TOOLS = [
|
|
|
177
177
|
type: 'object',
|
|
178
178
|
properties: {
|
|
179
179
|
id: { type: 'string', description: 'Note id to patch, as printed by recall_add or recall_search.' },
|
|
180
|
-
body: { type: 'string', description: 'The replacement note body.' },
|
|
180
|
+
body: { type: 'string', description: 'The replacement note body — brief, not a paragraph. ~20 (strict), 30 (balanced), or 50 (loose) words max — scales with recallStrictness — going over doesn\'t block the update, but prints a non-blocking warning.' },
|
|
181
181
|
ticket: { type: 'string', description: 'Ticket key the note is about, e.g. PROJ-123. Optional — narrows the search when omitted, the vault is searched across all ticket prefixes.' },
|
|
182
182
|
expectMtime: { type: 'number', description: 'The note file\'s mtime (ms) last observed by the caller. If the note changed since then, the patch is a no-op rather than an overwrite — optimistic concurrency, not a hard requirement.' },
|
|
183
183
|
},
|
|
@@ -10,10 +10,10 @@ 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 { checkNoteStructure } from './note-structural-check.mjs';
|
|
13
|
+
import { checkNoteStructure, checkWordCount, WORD_LIMITS } from './note-structural-check.mjs';
|
|
14
14
|
import { writeNote, patchNoteBody, deleteNote, deleteNoteAnyPrefix, rebuildIndex } from './recall-vault.mjs';
|
|
15
15
|
import { readCliToken } from './cli-auth.mjs';
|
|
16
|
-
import { resolveProfile, loadProfileRecallTeamId } from './profile-resolver.mjs';
|
|
16
|
+
import { resolveProfile, loadProfileRecallTeamId, resolveEffectiveRecallStrictness } from './profile-resolver.mjs';
|
|
17
17
|
import { pushNote } from './recall-sync.mjs';
|
|
18
18
|
import { enqueueNote, isRetryableFailure, maybeAutoFlush } from './recall-queue.mjs';
|
|
19
19
|
import { incrementDraftKept, incrementDraftDeleted } from './activity-counter.mjs';
|
|
@@ -103,10 +103,12 @@ export async function runNoteAdd(cmdArgs, {
|
|
|
103
103
|
readStdin = defaultReadStdin,
|
|
104
104
|
isLicensedFn = isLicensed,
|
|
105
105
|
checkNoteStructureFn = checkNoteStructure,
|
|
106
|
+
checkWordCountFn = checkWordCount,
|
|
106
107
|
scanForSecretsFn = scanForSecrets,
|
|
107
108
|
writeNoteFn = writeNote,
|
|
108
109
|
readCliTokenFn = readCliToken,
|
|
109
110
|
resolveProfileFn = resolveProfile,
|
|
111
|
+
resolveEffectiveRecallStrictnessFn = resolveEffectiveRecallStrictness,
|
|
110
112
|
loadProfileRecallTeamIdFn = loadProfileRecallTeamId,
|
|
111
113
|
pushNoteFn = pushNote,
|
|
112
114
|
enqueueNoteFn = enqueueNote,
|
|
@@ -153,6 +155,19 @@ export async function runNoteAdd(cmdArgs, {
|
|
|
153
155
|
return { written: false };
|
|
154
156
|
}
|
|
155
157
|
|
|
158
|
+
// Resolved once here (not just inside the push block below) so the
|
|
159
|
+
// word-count warning below can read this profile's effective recallStrictness
|
|
160
|
+
// regardless of whether a CLI token is configured for pushing. cliToken is
|
|
161
|
+
// read here too (not just in the push block) since resolveEffectiveRecallStrictness
|
|
162
|
+
// needs it to validate a team-default cache entry belongs to this account.
|
|
163
|
+
const profile = resolveProfileFn(ticketKey || null, { configDir, cwd: process.cwd() });
|
|
164
|
+
const cliToken = readCliTokenFn(configDir);
|
|
165
|
+
const strictness = resolveEffectiveRecallStrictnessFn({ profile, configDir, cliToken });
|
|
166
|
+
const wordCount = checkWordCountFn({ title, body }, { maxWords: WORD_LIMITS[strictness] });
|
|
167
|
+
for (const warning of wordCount.warnings) {
|
|
168
|
+
stream.write(` Warning: ${warning}\n`);
|
|
169
|
+
}
|
|
170
|
+
|
|
156
171
|
const scan = scanForSecretsFn({ title, tags, body });
|
|
157
172
|
if (scan.rejected) {
|
|
158
173
|
stream.write(` Note not saved — ${scan.reasons.join(' ')}\n`);
|
|
@@ -182,12 +197,11 @@ export async function runNoteAdd(cmdArgs, {
|
|
|
182
197
|
const savedLine = `Saved note "${title}" (${id})${attachSuffix}`;
|
|
183
198
|
stream.write(styled ? `\n ${s.green('✔')} ${savedLine}\n\n` : ` ${savedLine}\n`);
|
|
184
199
|
|
|
185
|
-
const cliToken = readCliTokenFn(configDir);
|
|
186
200
|
if (cliToken) {
|
|
187
201
|
const warn = (s) => stream.write(s);
|
|
188
202
|
// Field names match PushRequest's validation rules (external_id, tickets) —
|
|
189
203
|
// the backend wire contract, not the local vault's internal camelCase shape.
|
|
190
|
-
|
|
204
|
+
// `profile` was already resolved above (needed there for the word-count check).
|
|
191
205
|
const recallTeamId = profile ? loadProfileRecallTeamIdFn(profile.name, configDir) : null;
|
|
192
206
|
const payload = { external_id: id, title, tickets: ticketKeys, tags, author, sources: [], body, captured_at: capturedAt.toISOString() };
|
|
193
207
|
if (recallTeamId !== null) payload.group_id = recallTeamId;
|
|
@@ -225,6 +239,10 @@ export async function runNotePatch(cmdArgs, {
|
|
|
225
239
|
readStdin = defaultReadStdin,
|
|
226
240
|
isLicensedFn = isLicensed,
|
|
227
241
|
checkNoteStructureFn = checkNoteStructure,
|
|
242
|
+
checkWordCountFn = checkWordCount,
|
|
243
|
+
resolveProfileFn = resolveProfile,
|
|
244
|
+
readCliTokenFn = readCliToken,
|
|
245
|
+
resolveEffectiveRecallStrictnessFn = resolveEffectiveRecallStrictness,
|
|
228
246
|
scanForSecretsFn = scanForSecrets,
|
|
229
247
|
patchNoteBodyFn = patchNoteBody,
|
|
230
248
|
} = {}) {
|
|
@@ -253,6 +271,14 @@ export async function runNotePatch(cmdArgs, {
|
|
|
253
271
|
return { patched: false };
|
|
254
272
|
}
|
|
255
273
|
|
|
274
|
+
const profile = resolveProfileFn(ticketKey || null, { configDir, cwd: process.cwd() });
|
|
275
|
+
const cliToken = readCliTokenFn(configDir);
|
|
276
|
+
const strictness = resolveEffectiveRecallStrictnessFn({ profile, configDir, cliToken });
|
|
277
|
+
const wordCount = checkWordCountFn({ body }, { maxWords: WORD_LIMITS[strictness] });
|
|
278
|
+
for (const warning of wordCount.warnings) {
|
|
279
|
+
stream.write(` Warning: ${warning}\n`);
|
|
280
|
+
}
|
|
281
|
+
|
|
256
282
|
const scan = scanForSecretsFn({ title: '', tags: [], body });
|
|
257
283
|
if (scan.rejected) {
|
|
258
284
|
stream.write(` Note not updated — ${scan.reasons.join(' ')}\n`);
|
|
@@ -31,3 +31,39 @@ export function checkNoteStructure({ body = '' } = {}) {
|
|
|
31
31
|
|
|
32
32
|
return { rejected: false, reason: null };
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
// Backlog #18 — mandatory brevity cap, one entry per recallStrictness level.
|
|
36
|
+
// Warning-only: a rambling note still saves, it just gets flagged so the
|
|
37
|
+
// caller can tighten it, same non-blocking pattern scanForSecrets uses for
|
|
38
|
+
// its `warnings` array.
|
|
39
|
+
export const WORD_LIMITS = { strict: 20, balanced: 30, loose: 50 };
|
|
40
|
+
|
|
41
|
+
function countWords(text) {
|
|
42
|
+
const trimmed = text.trim();
|
|
43
|
+
return trimmed.length === 0 ? 0 : trimmed.split(/\s+/).length;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Warning-only companion to checkNoteStructure: flags a title/body that runs
|
|
48
|
+
* long instead of rejecting it. `maxWords` is a plain number so this stays
|
|
49
|
+
* dependency-free — the caller maps recallStrictness to a number.
|
|
50
|
+
*
|
|
51
|
+
* @param {{ title?: string, body?: string }} note
|
|
52
|
+
* @param {{ maxWords?: number }} opts
|
|
53
|
+
* @returns {{ warnings: string[] }}
|
|
54
|
+
*/
|
|
55
|
+
export function checkWordCount({ title = '', body = '' } = {}, { maxWords = WORD_LIMITS.balanced } = {}) {
|
|
56
|
+
const warnings = [];
|
|
57
|
+
|
|
58
|
+
const titleWords = countWords(title);
|
|
59
|
+
if (titleWords > maxWords) {
|
|
60
|
+
warnings.push(`Note title is ${titleWords} words (recommended max: ${maxWords}).`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const bodyWords = countWords(body);
|
|
64
|
+
if (bodyWords > maxWords) {
|
|
65
|
+
warnings.push(`Note body is ${bodyWords} words (recommended max: ${maxWords}).`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { warnings };
|
|
69
|
+
}
|
|
@@ -8,6 +8,10 @@ import { join } from 'node:path';
|
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
10
10
|
import { sanitizeUntrustedText } from './ansi.mjs';
|
|
11
|
+
import { readEffectiveRecallSettings } from './recall-settings-sync.mjs';
|
|
12
|
+
import { RECALL_STRICTNESS_LEVELS, DEFAULT_RECALL_STRICTNESS } from './recall-strictness.mjs';
|
|
13
|
+
|
|
14
|
+
export { RECALL_STRICTNESS_LEVELS, DEFAULT_RECALL_STRICTNESS };
|
|
11
15
|
|
|
12
16
|
/** Simple Levenshtein distance for "did you mean" suggestions. */
|
|
13
17
|
function levenshtein(a, b) {
|
|
@@ -100,13 +104,32 @@ export function saveProfile(name, profileData, credData, configDir = DEFAULT_CON
|
|
|
100
104
|
invalidateProfilesCache(configDir);
|
|
101
105
|
}
|
|
102
106
|
|
|
103
|
-
export const RECALL_STRICTNESS_LEVELS = ['loose', 'balanced', 'strict'];
|
|
104
|
-
export const DEFAULT_RECALL_STRICTNESS = 'balanced';
|
|
105
|
-
|
|
106
107
|
export function normalizeRecallStrictness(value) {
|
|
107
108
|
return RECALL_STRICTNESS_LEVELS.includes(value) ? value : DEFAULT_RECALL_STRICTNESS;
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Effective recallStrictness for a profile (backlog #20) — an explicit local
|
|
113
|
+
* `config set recallStrictness` always wins; only falls back to the team's
|
|
114
|
+
* Console-set default (read from the same local, network-free settings cache
|
|
115
|
+
* queue-settings already uses) when the profile has none. Synchronous and
|
|
116
|
+
* local-only by design — this is what the Stop hook calls, and it must never
|
|
117
|
+
* touch the network on every session end.
|
|
118
|
+
*
|
|
119
|
+
* @param {object} [opts]
|
|
120
|
+
* @param {{recallStrictness?: string}} [opts.profile]
|
|
121
|
+
* @param {string} [opts.configDir]
|
|
122
|
+
* @param {string} [opts.cliToken]
|
|
123
|
+
* @returns {string}
|
|
124
|
+
*/
|
|
125
|
+
export function resolveEffectiveRecallStrictness({ profile, configDir = DEFAULT_CONFIG_DIR, cliToken } = {}) {
|
|
126
|
+
if (profile?.recallStrictness && RECALL_STRICTNESS_LEVELS.includes(profile.recallStrictness)) {
|
|
127
|
+
return profile.recallStrictness;
|
|
128
|
+
}
|
|
129
|
+
const cached = readEffectiveRecallSettings(configDir, { cliToken });
|
|
130
|
+
return normalizeRecallStrictness(cached.recall_strictness);
|
|
131
|
+
}
|
|
132
|
+
|
|
110
133
|
export function saveProfileRecallStrictness(name, level, configDir = DEFAULT_CONFIG_DIR) {
|
|
111
134
|
const config = loadProfiles(configDir) || { profiles: {} };
|
|
112
135
|
if (!config.profiles[name]) throw new Error(`Unknown profile "${name}"`);
|
|
@@ -15,7 +15,7 @@ import { TICKET_KEY_PATTERN } from './cli.mjs';
|
|
|
15
15
|
import { isLicensed, showUpgradePrompt } from './license.mjs';
|
|
16
16
|
import { listNotes } from './recall-vault.mjs';
|
|
17
17
|
import { readCliToken } from './cli-auth.mjs';
|
|
18
|
-
import { resolveProfile, loadProfileRecallTeamId } from './profile-resolver.mjs';
|
|
18
|
+
import { resolveProfile, loadProfileRecallTeamId, resolveEffectiveRecallStrictness, RECALL_STRICTNESS_LEVELS } from './profile-resolver.mjs';
|
|
19
19
|
import { pullNotes } from './recall-sync.mjs';
|
|
20
20
|
import { maybeAutoFlush, flushQueue, readQueue } from './recall-queue.mjs';
|
|
21
21
|
import { getEffectiveRecallSettingsWithSource } from './recall-settings-sync.mjs';
|
|
@@ -128,7 +128,9 @@ export async function runRecallSettings(cmdArgs, {
|
|
|
128
128
|
stream = process.stdout,
|
|
129
129
|
isLicensedFn = isLicensed,
|
|
130
130
|
readCliTokenFn = readCliToken,
|
|
131
|
+
resolveProfileFn = resolveProfile,
|
|
131
132
|
getEffectiveRecallSettingsWithSourceFn = getEffectiveRecallSettingsWithSource,
|
|
133
|
+
resolveEffectiveRecallStrictnessFn = resolveEffectiveRecallStrictness,
|
|
132
134
|
} = {}) {
|
|
133
135
|
if (!isLicensedFn('pro', configDir)) {
|
|
134
136
|
showUpgradePrompt('pro', 'ticketlens recall', { stream });
|
|
@@ -143,5 +145,16 @@ export async function runRecallSettings(cmdArgs, {
|
|
|
143
145
|
stream.write(` Max queued notes: ${settings.max_queue_size}\n`);
|
|
144
146
|
stream.write(` Queued note expiry: ${settings.max_entry_age_ms / 86_400_000} days\n`);
|
|
145
147
|
stream.write(` Source: ${SOURCE_LABELS[source]}\n`);
|
|
148
|
+
|
|
149
|
+
// recall_strictness has its own precedence, separate from the queue
|
|
150
|
+
// settings above: an explicit local `config set recallStrictness` always
|
|
151
|
+
// wins over the team's Console default (backlog #20) — so its effective
|
|
152
|
+
// value and source are reported independently rather than reusing `source`.
|
|
153
|
+
const profile = resolveProfileFn(null, { configDir, cwd: process.cwd() });
|
|
154
|
+
const hasLocalOverride = RECALL_STRICTNESS_LEVELS.includes(profile?.recallStrictness);
|
|
155
|
+
const recallStrictness = resolveEffectiveRecallStrictnessFn({ profile, configDir, cliToken });
|
|
156
|
+
const strictnessSource = hasLocalOverride ? 'your local `config set recallStrictness`' : SOURCE_LABELS[source];
|
|
157
|
+
stream.write(` Recall capture strictness: ${recallStrictness}\n`);
|
|
158
|
+
stream.write(` Source: ${strictnessSource}\n`);
|
|
146
159
|
return { ok: true };
|
|
147
160
|
}
|
|
@@ -26,6 +26,7 @@ import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
|
26
26
|
import { apiBase } from './api-utils.mjs';
|
|
27
27
|
import { writeFileAtomically } from './recall-vault.mjs';
|
|
28
28
|
import { hashToken } from './recall-sync.mjs';
|
|
29
|
+
import { RECALL_STRICTNESS_LEVELS, DEFAULT_RECALL_STRICTNESS } from './recall-strictness.mjs';
|
|
29
30
|
|
|
30
31
|
const SETTINGS_PATH = '/v1/recall/settings';
|
|
31
32
|
const CACHE_FILE = 'recall-settings-cache.json';
|
|
@@ -37,14 +38,19 @@ const CACHE_FILE = 'recall-settings-cache.json';
|
|
|
37
38
|
const FETCH_TIMEOUT_MS = 3_000;
|
|
38
39
|
|
|
39
40
|
// Platform defaults — must match RecallSettings::DEFAULTS in ticketlens-api.
|
|
41
|
+
// recall_strictness (backlog #20) is a team default only — a profile's own
|
|
42
|
+
// explicit `config set recallStrictness` always wins over it; see
|
|
43
|
+
// profile-resolver.mjs's resolveEffectiveRecallStrictness().
|
|
40
44
|
export const DEFAULT_RECALL_SETTINGS = {
|
|
41
45
|
flush_cooldown_ms: 900_000, // 15 minutes
|
|
42
46
|
timeout_ms: 4_000, // 4 seconds
|
|
43
47
|
max_queue_size: 200,
|
|
44
48
|
max_entry_age_ms: 2_592_000_000, // 30 days
|
|
49
|
+
recall_strictness: DEFAULT_RECALL_STRICTNESS,
|
|
45
50
|
};
|
|
46
51
|
|
|
47
52
|
// Inclusive [min, max] bounds — must match RecallSettings::BOUNDS in ticketlens-api.
|
|
53
|
+
// recall_strictness is an enum, not a numeric range — see recall-strictness.mjs.
|
|
48
54
|
export const RECALL_SETTINGS_BOUNDS = {
|
|
49
55
|
flush_cooldown_ms: [60_000, 86_400_000], // 1m .. 24h
|
|
50
56
|
timeout_ms: [1_000, 30_000], // 1s .. 30s
|
|
@@ -57,6 +63,9 @@ function cachePath(configDir) {
|
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
function clamp(field, value) {
|
|
66
|
+
if (field === 'recall_strictness') {
|
|
67
|
+
return RECALL_STRICTNESS_LEVELS.includes(value) ? value : DEFAULT_RECALL_SETTINGS.recall_strictness;
|
|
68
|
+
}
|
|
60
69
|
if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_RECALL_SETTINGS[field];
|
|
61
70
|
const [min, max] = RECALL_SETTINGS_BOUNDS[field];
|
|
62
71
|
return Math.min(Math.max(value, min), max);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three Recall-capture strictness levels — single source of truth.
|
|
3
|
+
* Zero imports of its own (a leaf module) so both profile-resolver.mjs and
|
|
4
|
+
* recall-settings-sync.mjs can import it without risking a circular import
|
|
5
|
+
* between those two.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const RECALL_STRICTNESS_LEVELS = ['loose', 'balanced', 'strict'];
|
|
9
|
+
export const DEFAULT_RECALL_STRICTNESS = 'balanced';
|