ticketlens 0.38.45 → 0.38.47
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 +6 -4
- 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-server.mjs +4 -1
- package/skills/jtb/scripts/lib/mcp-tool-schemas.mjs +1 -0
- package/skills/jtb/scripts/lib/note-command.mjs +26 -10
- 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/skills/jtb/scripts/lib/recall-vault.mjs +20 -6
package/README.md
CHANGED
|
@@ -452,15 +452,17 @@ 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
|
-
**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).
|
|
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; its `attachments` array appends new files to whatever the note already has, same as `recall_add`'s, never replacing existing ones. `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).
|
|
458
460
|
|
|
459
461
|
`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. Each result shows a relative time (`2h ago`, `3d ago`) rather than a bare date — the full-precision timestamp is always in the note file's own frontmatter.
|
|
460
462
|
|
|
461
463
|
**Tags matter for search relevance.** `--tags=a,b` accepts anything, but a generic tag (the project name, "gotcha", "bug") gives future search almost nothing to match on. Tag with what the note is actually *about* — the specific technology, error type, or root cause (`retry-backoff`, `null-pointer`, `auth-middleware`) — so it surfaces when someone else hits the same problem.
|
|
462
464
|
|
|
463
|
-
**Local file attachments.** `note add --attach=path1,path2` (or the `recall_add` MCP tool's `attachments` array) saves a screenshot or file alongside the note, in your local vault (`~/.ticketlens/recall/<PREFIX>/<note-id>/`) — same 10 MB/file, 50 MB/call, 20-file caps as ticket attachments for the local save. With Team Recall sync active, the attachment syncs too — visible and downloadable from Console > Admin > Recall — but the sync path caps at 12 MB/call (the backend's request-size limit, lower than the 50 MB local-save cap). Going over it fails the whole push, not just the attachment — the note stays saved locally, but neither its text nor the attachment reaches the team until it's pushed within the cap. Attachments with plain-text content (detected from the actual bytes, not the filename) go through the same secret scan as the note body before syncing; a rejected scan blocks the whole push the same way.
|
|
465
|
+
**Local file attachments.** `note add --attach=path1,path2` (or the `recall_add` MCP tool's `attachments` array) saves a screenshot or file alongside the note, in your local vault (`~/.ticketlens/recall/<PREFIX>/<note-id>/`) — same 10 MB/file, 50 MB/call, 20-file caps as ticket attachments for the local save. `note patch --attach=path1,path2` (or `recall_update`'s `attachments` array) attaches more files to an existing note later — appended to what's already there, never replacing it; patch is local-vault only, so these are never pushed even if the note's original attachments were. With Team Recall sync active, the attachment syncs too — visible and downloadable from Console > Admin > Recall — but the sync path caps at 12 MB/call (the backend's request-size limit, lower than the 50 MB local-save cap). Going over it fails the whole push, not just the attachment — the note stays saved locally, but neither its text nor the attachment reaches the team until it's pushed within the cap. Attachments with plain-text content (detected from the actual bytes, not the filename) go through the same secret scan as the note body before syncing; a rejected scan blocks the whole push the same way.
|
|
464
466
|
|
|
465
467
|
**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.
|
|
466
468
|
|
|
@@ -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.6 -->
|
|
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)
|
|
@@ -397,7 +397,9 @@ When it does apply, run up to 3 rounds:
|
|
|
397
397
|
```
|
|
398
398
|
`--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.
|
|
399
399
|
|
|
400
|
-
|
|
400
|
+
`note patch` also takes `--attach=path1,path2` (Pro), same caps and secret-scan treatment as `note add`'s — new files are appended to whatever the note already has, never replacing existing attachments.
|
|
401
|
+
|
|
402
|
+
If this harness has TicketLens's MCP server configured (a tool named `recall_update` — often shown as `mcp__ticketlens__recall_update` — visible in your tool list), prefer it over the bash form: same license gate, same structural/secret-scan checks, same optimistic-concurrency behavior via `expectMtime` — just no shell command or stdin piping to construct. It accepts `id`/`body`/`ticket`/`expectMtime`/`attachments`.
|
|
401
403
|
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.
|
|
402
404
|
|
|
403
405
|
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.
|
|
@@ -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 = 'http://api.ticketlens.test';
|
|
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]')}`,
|
|
@@ -393,11 +393,14 @@ async function callRecallAdd(args, { configDir, runNoteAddFn }) {
|
|
|
393
393
|
* as buildNoteAddArgs above. `expectMtime` is optimistic-concurrency: a
|
|
394
394
|
* caller that fetched a note via recall_search and wants to refine it
|
|
395
395
|
* without racing a concurrent edit passes back the mtime it observed.
|
|
396
|
+
* `attachments` (backlog #21) is appended to whatever the note already has,
|
|
397
|
+
* same as buildNoteAddArgs's — never a replace.
|
|
396
398
|
*/
|
|
397
|
-
function buildNotePatchArgs({ id, ticket, expectMtime }) {
|
|
399
|
+
function buildNotePatchArgs({ id, ticket, expectMtime, attachments }) {
|
|
398
400
|
const args = [`--id=${id}`];
|
|
399
401
|
if (ticket) args.push(`--ticket=${ticket}`);
|
|
400
402
|
if (expectMtime !== undefined) args.push(`--expect-mtime=${expectMtime}`);
|
|
403
|
+
if (Array.isArray(attachments) && attachments.length > 0) args.push(`--attach=${attachments.join(',')}`);
|
|
401
404
|
return args;
|
|
402
405
|
}
|
|
403
406
|
|
|
@@ -180,6 +180,7 @@ export const TOOLS = [
|
|
|
180
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
|
+
attachments: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach (screenshots, logs, etc.) — appended to whatever the note already has, never replacing existing attachments. Same 10MB/file, 50MB/call, 20-file caps as recall_add. Local vault only — recall_update never syncs to a team backend, so this never pushes.' },
|
|
183
184
|
},
|
|
184
185
|
required: ['id', 'body'],
|
|
185
186
|
},
|
|
@@ -13,7 +13,7 @@ import { scanForSecrets } from './secret-scanner.mjs';
|
|
|
13
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,
|
|
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';
|
|
@@ -108,6 +108,7 @@ export async function runNoteAdd(cmdArgs, {
|
|
|
108
108
|
writeNoteFn = writeNote,
|
|
109
109
|
readCliTokenFn = readCliToken,
|
|
110
110
|
resolveProfileFn = resolveProfile,
|
|
111
|
+
resolveEffectiveRecallStrictnessFn = resolveEffectiveRecallStrictness,
|
|
111
112
|
loadProfileRecallTeamIdFn = loadProfileRecallTeamId,
|
|
112
113
|
pushNoteFn = pushNote,
|
|
113
114
|
enqueueNoteFn = enqueueNote,
|
|
@@ -155,10 +156,13 @@ export async function runNoteAdd(cmdArgs, {
|
|
|
155
156
|
}
|
|
156
157
|
|
|
157
158
|
// Resolved once here (not just inside the push block below) so the
|
|
158
|
-
// word-count warning below can read this profile's recallStrictness
|
|
159
|
-
// regardless of whether a CLI token is configured for pushing.
|
|
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.
|
|
160
163
|
const profile = resolveProfileFn(ticketKey || null, { configDir, cwd: process.cwd() });
|
|
161
|
-
const
|
|
164
|
+
const cliToken = readCliTokenFn(configDir);
|
|
165
|
+
const strictness = resolveEffectiveRecallStrictnessFn({ profile, configDir, cliToken });
|
|
162
166
|
const wordCount = checkWordCountFn({ title, body }, { maxWords: WORD_LIMITS[strictness] });
|
|
163
167
|
for (const warning of wordCount.warnings) {
|
|
164
168
|
stream.write(` Warning: ${warning}\n`);
|
|
@@ -193,7 +197,6 @@ export async function runNoteAdd(cmdArgs, {
|
|
|
193
197
|
const savedLine = `Saved note "${title}" (${id})${attachSuffix}`;
|
|
194
198
|
stream.write(styled ? `\n ${s.green('✔')} ${savedLine}\n\n` : ` ${savedLine}\n`);
|
|
195
199
|
|
|
196
|
-
const cliToken = readCliTokenFn(configDir);
|
|
197
200
|
if (cliToken) {
|
|
198
201
|
const warn = (s) => stream.write(s);
|
|
199
202
|
// Field names match PushRequest's validation rules (external_id, tickets) —
|
|
@@ -225,7 +228,9 @@ export async function runNoteAdd(cmdArgs, {
|
|
|
225
228
|
* meant to be typed by hand): the loop's generator subagent produces a body,
|
|
226
229
|
* SKILL.md's orchestration pipes it through this command, and the new body
|
|
227
230
|
* gets exactly the same structural and secret gates a user-typed body gets —
|
|
228
|
-
* there is no weaker path here for AI-authored content.
|
|
231
|
+
* there is no weaker path here for AI-authored content. `--attach` works the
|
|
232
|
+
* same as `note add`'s — files are appended to whatever the note already has,
|
|
233
|
+
* never replacing them.
|
|
229
234
|
*
|
|
230
235
|
* @param {string[]} cmdArgs
|
|
231
236
|
* @returns {Promise<{ patched: boolean }>}
|
|
@@ -238,8 +243,11 @@ export async function runNotePatch(cmdArgs, {
|
|
|
238
243
|
checkNoteStructureFn = checkNoteStructure,
|
|
239
244
|
checkWordCountFn = checkWordCount,
|
|
240
245
|
resolveProfileFn = resolveProfile,
|
|
246
|
+
readCliTokenFn = readCliToken,
|
|
247
|
+
resolveEffectiveRecallStrictnessFn = resolveEffectiveRecallStrictness,
|
|
241
248
|
scanForSecretsFn = scanForSecrets,
|
|
242
249
|
patchNoteBodyFn = patchNoteBody,
|
|
250
|
+
readAttachmentsFn = readAttachments,
|
|
243
251
|
} = {}) {
|
|
244
252
|
if (!isLicensedFn('pro', configDir)) {
|
|
245
253
|
showUpgradePrompt('pro', 'ticketlens note', { stream });
|
|
@@ -248,7 +256,7 @@ export async function runNotePatch(cmdArgs, {
|
|
|
248
256
|
|
|
249
257
|
const id = parseFlag(cmdArgs, 'id');
|
|
250
258
|
if (!id) {
|
|
251
|
-
stream.write('Usage: ticketlens note patch --id="..." [--ticket=KEY]\n');
|
|
259
|
+
stream.write('Usage: ticketlens note patch --id="..." [--ticket=KEY] [--attach=path1,path2]\n');
|
|
252
260
|
return { patched: false };
|
|
253
261
|
}
|
|
254
262
|
|
|
@@ -267,7 +275,8 @@ export async function runNotePatch(cmdArgs, {
|
|
|
267
275
|
}
|
|
268
276
|
|
|
269
277
|
const profile = resolveProfileFn(ticketKey || null, { configDir, cwd: process.cwd() });
|
|
270
|
-
const
|
|
278
|
+
const cliToken = readCliTokenFn(configDir);
|
|
279
|
+
const strictness = resolveEffectiveRecallStrictnessFn({ profile, configDir, cliToken });
|
|
271
280
|
const wordCount = checkWordCountFn({ body }, { maxWords: WORD_LIMITS[strictness] });
|
|
272
281
|
for (const warning of wordCount.warnings) {
|
|
273
282
|
stream.write(` Warning: ${warning}\n`);
|
|
@@ -282,11 +291,18 @@ export async function runNotePatch(cmdArgs, {
|
|
|
282
291
|
stream.write(` Warning: ${warning}\n`);
|
|
283
292
|
}
|
|
284
293
|
|
|
294
|
+
const attachPaths = parseAttachPaths(cmdArgs);
|
|
295
|
+
const { saved: attachments, warnings: attachWarnings } = attachPaths.length > 0
|
|
296
|
+
? summarizeAttachments(readAttachmentsFn(attachPaths))
|
|
297
|
+
: { saved: [], warnings: [] };
|
|
298
|
+
for (const warning of attachWarnings) stream.write(warning);
|
|
299
|
+
|
|
285
300
|
const ticketKeys = ticketKey ? [ticketKey] : [];
|
|
286
301
|
const expectMtimeArg = parseFlag(cmdArgs, 'expect-mtime');
|
|
287
302
|
const expectedMtimeMs = expectMtimeArg !== undefined ? Number(expectMtimeArg) : undefined;
|
|
288
|
-
const { patched } = patchNoteBodyFn({ id, ticketKeys, body, expectedMtimeMs }, { configDir });
|
|
289
|
-
|
|
303
|
+
const { patched } = patchNoteBodyFn({ id, ticketKeys, body, expectedMtimeMs, attachments }, { configDir });
|
|
304
|
+
const attachSuffix = patched && attachments.length > 0 ? ` + ${attachments.length} attachment(s)` : '';
|
|
305
|
+
stream.write(patched ? ` Updated note (${id})${attachSuffix}\n` : ` Note not updated — (${id}) not found or already changed.\n`);
|
|
290
306
|
return { patched };
|
|
291
307
|
}
|
|
292
308
|
|
|
@@ -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';
|
|
@@ -90,7 +90,10 @@ function writeAttachments(noteDir, id, attachments) {
|
|
|
90
90
|
if (attachments.length === 0) return [];
|
|
91
91
|
const attachDir = attachmentsDirFor(noteDir, id);
|
|
92
92
|
fs.mkdirSync(attachDir, { recursive: true });
|
|
93
|
-
|
|
93
|
+
// Seeded from what's already on disk, not just this call's batch — patchNoteBody
|
|
94
|
+
// can call this a second time against the same note id, and a same-named file
|
|
95
|
+
// from an earlier add/patch must count toward uniqueness too.
|
|
96
|
+
const used = new Set(fs.readdirSync(attachDir));
|
|
94
97
|
const saved = [];
|
|
95
98
|
for (const { filename, buffer } of attachments) {
|
|
96
99
|
const uniqueName = uniquifyFilename(filename, used);
|
|
@@ -248,8 +251,10 @@ export function deleteNoteAnyPrefix(externalId, { configDir = DEFAULT_CONFIG_DIR
|
|
|
248
251
|
* Overwrites an existing local note's body in place — used by the jtb skill's
|
|
249
252
|
* generator/validator quality loop to swap in a better draft after `note add`
|
|
250
253
|
* already saved the original. Patch-only: never creates a note, and every
|
|
251
|
-
* frontmatter field except body is carried over unchanged, so
|
|
252
|
-
* become a covert way to retitle/retag/re-tie a note to a
|
|
254
|
+
* frontmatter field except body and attachments is carried over unchanged, so
|
|
255
|
+
* this can never become a covert way to retitle/retag/re-tie a note to a
|
|
256
|
+
* different ticket. New attachments are appended to whatever the note already
|
|
257
|
+
* has — a patch refines a note, it never discards what's already attached.
|
|
253
258
|
*
|
|
254
259
|
* Guards, same failure-mode split as deleteNote: a malformed id or ticket key
|
|
255
260
|
* is a caller bug (throw); a missing file, an externalId that doesn't match
|
|
@@ -257,17 +262,18 @@ export function deleteNoteAnyPrefix(externalId, { configDir = DEFAULT_CONFIG_DIR
|
|
|
257
262
|
* (expectedMtimeMs) are all best-effort no-ops, not errors — the original
|
|
258
263
|
* note is always left exactly as-is on any of these.
|
|
259
264
|
*
|
|
260
|
-
* @param {{ id: string, ticketKeys?: string[], body: string, expectedMtimeMs?: number }} params
|
|
265
|
+
* @param {{ id: string, ticketKeys?: string[], body: string, expectedMtimeMs?: number, attachments?: Array<{ filename: string, buffer: Buffer }> }} params
|
|
261
266
|
* @param {{ configDir?: string }} [opts]
|
|
262
267
|
* @returns {{ patched: boolean, path: string|null }}
|
|
263
268
|
*/
|
|
264
|
-
export function patchNoteBody({ id, ticketKeys = [], body, expectedMtimeMs }, { configDir = DEFAULT_CONFIG_DIR } = {}) {
|
|
269
|
+
export function patchNoteBody({ id, ticketKeys = [], body, expectedMtimeMs, attachments = [] }, { configDir = DEFAULT_CONFIG_DIR } = {}) {
|
|
265
270
|
if (!EXTERNAL_ID_PATTERN.test(id)) {
|
|
266
271
|
throw new Error(`Invalid note id: "${id}"`);
|
|
267
272
|
}
|
|
268
273
|
|
|
269
274
|
const prefix = resolvePrefix(ticketKeys[0]);
|
|
270
|
-
const
|
|
275
|
+
const dir = prefixDir(configDir, prefix);
|
|
276
|
+
const notePath = path.join(dir, id);
|
|
271
277
|
|
|
272
278
|
if (!fs.existsSync(notePath)) {
|
|
273
279
|
return { patched: false, path: null };
|
|
@@ -281,6 +287,13 @@ export function patchNoteBody({ id, ticketKeys = [], body, expectedMtimeMs }, {
|
|
|
281
287
|
return { patched: false, path: notePath };
|
|
282
288
|
}
|
|
283
289
|
|
|
290
|
+
// existing.attachments is the full list already on disk (from add and/or a
|
|
291
|
+
// prior patch) — carried forward here, or the drop bug returns: patch would
|
|
292
|
+
// silently strip the attachments: key, orphaning files that stay on disk but
|
|
293
|
+
// become permanently unlisted and unpushable.
|
|
294
|
+
const savedAttachments = writeAttachments(dir, id, attachments);
|
|
295
|
+
const mergedAttachments = [...existing.attachments, ...savedAttachments];
|
|
296
|
+
|
|
284
297
|
const data = {
|
|
285
298
|
title: existing.title,
|
|
286
299
|
aliases: existing.aliases,
|
|
@@ -291,6 +304,7 @@ export function patchNoteBody({ id, ticketKeys = [], body, expectedMtimeMs }, {
|
|
|
291
304
|
status: existing.status,
|
|
292
305
|
sources: existing.sources,
|
|
293
306
|
externalId: existing.externalId,
|
|
307
|
+
...(mergedAttachments.length > 0 ? { attachments: mergedAttachments } : {}),
|
|
294
308
|
};
|
|
295
309
|
|
|
296
310
|
writeFileAtomically(notePath, serializeFrontmatter(data, body));
|