ticketlens 0.16.1 → 0.18.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 +7 -1
- package/bin/ticketlens.mjs +30 -10
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +24 -2
- package/skills/jtb/scripts/lib/help.mjs +12 -0
- package/skills/jtb/scripts/lib/note-command.mjs +83 -5
- package/skills/jtb/scripts/lib/note-structural-check.mjs +33 -0
- package/skills/jtb/scripts/lib/recall-command.mjs +41 -0
- package/skills/jtb/scripts/lib/recall-queue.mjs +227 -0
- package/skills/jtb/scripts/lib/recall-sync.mjs +6 -2
- package/skills/jtb/scripts/lib/recall-vault.mjs +54 -1
package/README.md
CHANGED
|
@@ -394,10 +394,14 @@ 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
|
|
|
403
|
+
**Offline resilience:** if a team push fails for a transient reason (network error, timeout, or a 5xx from the backend), the note stays safely in your local vault and is queued for retry — nothing is lost. The queue flushes automatically in the background (at most once every 15 minutes, whenever `recall` or `note add` next talks to the network), or on demand with `ticketlens recall sync`. A session-expired (401) or not-entitled (403) push is never queued — those need you to act (`ticketlens login`, or an owner grant), not a retry. Queued entries expire after 30 days and are capped at 200; switching accounts never flushes a note under the wrong login.
|
|
404
|
+
|
|
401
405
|
`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.
|
|
402
406
|
|
|
403
407
|
**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.
|
|
@@ -672,6 +676,7 @@ ticketlens history <TICKET-KEY> # Show urgency timeline for a tick
|
|
|
672
676
|
echo "note body" | ticketlens note add --title="..." --ticket=CNV1-2 --tags=a,b # Save a note [Pro]
|
|
673
677
|
ticketlens recall CNV1-2 # Search saved notes by ticket key [Pro]
|
|
674
678
|
ticketlens recall "retry backoff" # Free-text search across all notes [Pro]
|
|
679
|
+
ticketlens recall sync # Retry any notes stuck in the local queue [Pro]
|
|
675
680
|
|
|
676
681
|
# ── Stats ──────────────────────────────────────────────────────────────────────
|
|
677
682
|
ticketlens stats # Response-time metrics from local history
|
|
@@ -750,6 +755,7 @@ ticketlens triage --digest # POST scored triage results to digest
|
|
|
750
755
|
ticketlens schedule # Set up a scheduled daily digest
|
|
751
756
|
ticketlens note add --title="..." # Save a Recall note (body from stdin)
|
|
752
757
|
ticketlens recall <query|TICKET-KEY> # Search your saved Recall notes
|
|
758
|
+
ticketlens recall sync # Retry any notes stuck in the local queue
|
|
753
759
|
ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
|
|
754
760
|
```
|
|
755
761
|
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -610,23 +610,43 @@ 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
|
|
|
628
638
|
case 'recall': {
|
|
629
639
|
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printRecallHelp(); break; }
|
|
640
|
+
if (cmdArgs[0] === 'sync') {
|
|
641
|
+
const { runRecallSync } = await import('../skills/jtb/scripts/lib/recall-command.mjs');
|
|
642
|
+
runRecallSync(cmdArgs.slice(1)).then(({ ok }) => {
|
|
643
|
+
if (!ok) process.exitCode = 1;
|
|
644
|
+
}).catch(err => {
|
|
645
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
646
|
+
process.exitCode = 1;
|
|
647
|
+
});
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
630
650
|
const { runRecall } = await import('../skills/jtb/scripts/lib/recall-command.mjs');
|
|
631
651
|
runRecall(cmdArgs).then(({ ok }) => {
|
|
632
652
|
if (!ok) process.exitCode = 1;
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.18.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.
|
|
@@ -53,6 +53,7 @@ Fetches a Jira ticket and produces a structured brief with code references, then
|
|
|
53
53
|
/jtb cloud-keys timeout anthropic 15 # set per-request timeout in seconds
|
|
54
54
|
/jtb note "gotcha text" --ticket=PROD-1234 # save a Recall note (Pro)
|
|
55
55
|
/jtb recall PROD-1234 # search saved Recall notes (Pro)
|
|
56
|
+
/jtb recall sync # retry any notes stuck in the local queue (Pro)
|
|
56
57
|
```
|
|
57
58
|
|
|
58
59
|
## Prerequisites
|
|
@@ -219,8 +220,29 @@ echo "The body text of the note, one or more paragraphs." | \
|
|
|
219
220
|
|
|
220
221
|
To search saved notes directly (outside of automatic brief injection): `ticketlens recall "<query>"`.
|
|
221
222
|
|
|
223
|
+
### Quality loop (Pro, in-session only)
|
|
224
|
+
|
|
225
|
+
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.
|
|
226
|
+
|
|
227
|
+
When it does apply, run up to 3 rounds:
|
|
228
|
+
|
|
229
|
+
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.
|
|
230
|
+
2. **Generator** — a subagent drafts an improved body: concrete file/line references over vague prose, no invented facts not already established this session.
|
|
231
|
+
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).
|
|
232
|
+
4. If the draft scores as a genuine improvement, write it back:
|
|
233
|
+
```bash
|
|
234
|
+
echo "The improved body text." | \
|
|
235
|
+
ticketlens note patch --id="THE-ID-PRINTED-ABOVE" --ticket=TICKET-KEY --expect-mtime="THE-MTIME-FROM-STEP-1"
|
|
236
|
+
```
|
|
237
|
+
`--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.
|
|
238
|
+
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.
|
|
239
|
+
|
|
240
|
+
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.
|
|
241
|
+
|
|
242
|
+
**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.
|
|
243
|
+
|
|
222
244
|
### Privacy
|
|
223
|
-
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.
|
|
245
|
+
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. If a team push fails for a transient reason (network error, timeout, 5xx), the note is queued locally and retried automatically in the background, or on demand with `ticketlens recall sync` — a session-expired or not-entitled push is never queued, since retrying those can't succeed without the user acting first.
|
|
224
246
|
|
|
225
247
|
---
|
|
226
248
|
|
|
@@ -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
|
}
|
|
@@ -529,12 +536,17 @@ export function printRecallHelp({ stream = process.stdout } = {}) {
|
|
|
529
536
|
` ${s.brand('--full')} Print each matching note's full body content`,
|
|
530
537
|
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
531
538
|
'',
|
|
539
|
+
` ${s.bold('COMMANDS')}`,
|
|
540
|
+
'',
|
|
541
|
+
` ${s.brand('sync')} Manually retry any team-synced notes stuck in the local retry queue ${s.dim('[Pro, requires login]')}`,
|
|
542
|
+
'',
|
|
532
543
|
` ${s.bold('EXAMPLES')}`,
|
|
533
544
|
'',
|
|
534
545
|
` ${s.dim('$')} ticketlens recall PROD-123`,
|
|
535
546
|
` ${s.dim('$')} ticketlens recall "retry backoff"`,
|
|
536
547
|
` ${s.dim('$')} ticketlens recall PROD-123 --plain`,
|
|
537
548
|
` ${s.dim('$')} ticketlens recall PROD-123 --full`,
|
|
549
|
+
` ${s.dim('$')} ticketlens recall sync`,
|
|
538
550
|
'',
|
|
539
551
|
];
|
|
540
552
|
stream.write(lines.join('\n') + '\n');
|
|
@@ -10,9 +10,11 @@ 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';
|
|
17
|
+
import { enqueueNote, isRetryableFailure, maybeAutoFlush } from './recall-queue.mjs';
|
|
16
18
|
import { incrementDraftKept, incrementDraftDeleted } from './activity-counter.mjs';
|
|
17
19
|
import { extractText } from './attachment-text.mjs';
|
|
18
20
|
import { TICKET_KEY_PATTERN } from './cli.mjs';
|
|
@@ -66,10 +68,14 @@ export async function runNoteAdd(cmdArgs, {
|
|
|
66
68
|
stream = process.stderr,
|
|
67
69
|
readStdin = defaultReadStdin,
|
|
68
70
|
isLicensedFn = isLicensed,
|
|
71
|
+
checkNoteStructureFn = checkNoteStructure,
|
|
69
72
|
scanForSecretsFn = scanForSecrets,
|
|
70
73
|
writeNoteFn = writeNote,
|
|
71
74
|
readCliTokenFn = readCliToken,
|
|
72
75
|
pushNoteFn = pushNote,
|
|
76
|
+
enqueueNoteFn = enqueueNote,
|
|
77
|
+
isRetryableFailureFn = isRetryableFailure,
|
|
78
|
+
maybeAutoFlushFn = maybeAutoFlush,
|
|
73
79
|
incrementDraftKeptFn = incrementDraftKept,
|
|
74
80
|
incrementDraftDeletedFn = incrementDraftDeleted,
|
|
75
81
|
listAttachmentsFn = defaultListAttachments,
|
|
@@ -103,6 +109,13 @@ export async function runNoteAdd(cmdArgs, {
|
|
|
103
109
|
body += gatherAttachmentExcerpts(configDir, ticketKey, listAttachmentsFn, extractTextFn);
|
|
104
110
|
}
|
|
105
111
|
|
|
112
|
+
const structural = checkNoteStructureFn({ body });
|
|
113
|
+
if (structural.rejected) {
|
|
114
|
+
stream.write(` Note not saved — ${structural.reason}\n`);
|
|
115
|
+
incrementDraftDeletedFn(configDir);
|
|
116
|
+
return { written: false };
|
|
117
|
+
}
|
|
118
|
+
|
|
106
119
|
const scan = scanForSecretsFn({ title, tags, body });
|
|
107
120
|
if (scan.rejected) {
|
|
108
121
|
stream.write(` Note not saved — ${scan.reasons.join(' ')}\n`);
|
|
@@ -122,13 +135,78 @@ export async function runNoteAdd(cmdArgs, {
|
|
|
122
135
|
|
|
123
136
|
const cliToken = readCliTokenFn(configDir);
|
|
124
137
|
if (cliToken) {
|
|
138
|
+
const warn = (s) => stream.write(s);
|
|
125
139
|
// Field names match PushRequest's validation rules (external_id, tickets) —
|
|
126
140
|
// the backend wire contract, not the local vault's internal camelCase shape.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
141
|
+
const payload = { external_id: id, title, tickets: ticketKeys, tags, author, sources: [], body };
|
|
142
|
+
const result = await pushNoteFn(payload, { cliToken, configDir, warn });
|
|
143
|
+
if (isRetryableFailureFn(result)) {
|
|
144
|
+
enqueueNoteFn(payload, { cliToken, configDir, warn });
|
|
145
|
+
}
|
|
146
|
+
await maybeAutoFlushFn({ cliToken, configDir });
|
|
131
147
|
}
|
|
132
148
|
|
|
133
149
|
return { written: true };
|
|
134
150
|
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Implements `tl note patch` — overwrites an existing note's body with a
|
|
154
|
+
* better draft. Internal plumbing for the jtb skill's quality loop (never
|
|
155
|
+
* meant to be typed by hand): the loop's generator subagent produces a body,
|
|
156
|
+
* SKILL.md's orchestration pipes it through this command, and the new body
|
|
157
|
+
* gets exactly the same structural and secret gates a user-typed body gets —
|
|
158
|
+
* there is no weaker path here for AI-authored content.
|
|
159
|
+
*
|
|
160
|
+
* @param {string[]} cmdArgs
|
|
161
|
+
* @returns {Promise<{ patched: boolean }>}
|
|
162
|
+
*/
|
|
163
|
+
export async function runNotePatch(cmdArgs, {
|
|
164
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
165
|
+
stream = process.stderr,
|
|
166
|
+
readStdin = defaultReadStdin,
|
|
167
|
+
isLicensedFn = isLicensed,
|
|
168
|
+
checkNoteStructureFn = checkNoteStructure,
|
|
169
|
+
scanForSecretsFn = scanForSecrets,
|
|
170
|
+
patchNoteBodyFn = patchNoteBody,
|
|
171
|
+
} = {}) {
|
|
172
|
+
if (!isLicensedFn('pro', configDir)) {
|
|
173
|
+
showUpgradePrompt('pro', 'ticketlens note', { stream });
|
|
174
|
+
return { patched: false };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const id = parseFlag(cmdArgs, 'id');
|
|
178
|
+
if (!id) {
|
|
179
|
+
stream.write('Usage: ticketlens note patch --id="..." [--ticket=KEY]\n');
|
|
180
|
+
return { patched: false };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const ticketKey = parseFlag(cmdArgs, 'ticket');
|
|
184
|
+
if (ticketKey && !TICKET_KEY_PATTERN.test(ticketKey)) {
|
|
185
|
+
stream.write(` Invalid --ticket value "${ticketKey}" — expected a ticket key like PROJ-123.\n`);
|
|
186
|
+
return { patched: false };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const body = await readStdin();
|
|
190
|
+
|
|
191
|
+
const structural = checkNoteStructureFn({ body });
|
|
192
|
+
if (structural.rejected) {
|
|
193
|
+
stream.write(` Note not updated — ${structural.reason}\n`);
|
|
194
|
+
return { patched: false };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const scan = scanForSecretsFn({ title: '', tags: [], body });
|
|
198
|
+
if (scan.rejected) {
|
|
199
|
+
stream.write(` Note not updated — ${scan.reasons.join(' ')}\n`);
|
|
200
|
+
return { patched: false };
|
|
201
|
+
}
|
|
202
|
+
for (const warning of scan.warnings) {
|
|
203
|
+
stream.write(` Warning: ${warning}\n`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const ticketKeys = ticketKey ? [ticketKey] : [];
|
|
207
|
+
const expectMtimeArg = parseFlag(cmdArgs, 'expect-mtime');
|
|
208
|
+
const expectedMtimeMs = expectMtimeArg !== undefined ? Number(expectMtimeArg) : undefined;
|
|
209
|
+
const { patched } = patchNoteBodyFn({ id, ticketKeys, body, expectedMtimeMs }, { configDir });
|
|
210
|
+
stream.write(patched ? ` Updated note (${id})\n` : ` Note not updated — (${id}) not found or already changed.\n`);
|
|
211
|
+
return { patched };
|
|
212
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -12,6 +12,7 @@ import { isLicensed, showUpgradePrompt } from './license.mjs';
|
|
|
12
12
|
import { listNotes } from './recall-vault.mjs';
|
|
13
13
|
import { readCliToken } from './cli-auth.mjs';
|
|
14
14
|
import { pullNotes } from './recall-sync.mjs';
|
|
15
|
+
import { maybeAutoFlush, flushQueue, readQueue } from './recall-queue.mjs';
|
|
15
16
|
import { styleRecallResults } from './styled-assembler.mjs';
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -26,6 +27,7 @@ export async function runRecall(cmdArgs, {
|
|
|
26
27
|
listNotesFn = listNotes,
|
|
27
28
|
readCliTokenFn = readCliToken,
|
|
28
29
|
pullNotesFn = pullNotes,
|
|
30
|
+
maybeAutoFlushFn = maybeAutoFlush,
|
|
29
31
|
} = {}) {
|
|
30
32
|
if (!isLicensedFn('pro', configDir)) {
|
|
31
33
|
showUpgradePrompt('pro', 'ticketlens recall', { stream: errorStream });
|
|
@@ -45,6 +47,7 @@ export async function runRecall(cmdArgs, {
|
|
|
45
47
|
configDir,
|
|
46
48
|
...(cmdArgs.includes('--no-cache') && { ttlMs: 0 }),
|
|
47
49
|
});
|
|
50
|
+
await maybeAutoFlushFn({ cliToken, configDir });
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
const filter = TICKET_KEY_PATTERN.test(arg) ? { ticketKey: arg } : { query: arg };
|
|
@@ -55,3 +58,41 @@ export async function runRecall(cmdArgs, {
|
|
|
55
58
|
stream.write(styleRecallResults(results, { styled, full }) + '\n');
|
|
56
59
|
return { ok: true };
|
|
57
60
|
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Implements `tl recall sync` — manually flushes the local retry queue
|
|
64
|
+
* (notes whose team push previously failed for a transient reason). Unlike
|
|
65
|
+
* the auto-flush attempted from `runRecall`/`note add`, this is an explicit
|
|
66
|
+
* user action, so failures are reported visibly rather than staying silent.
|
|
67
|
+
*
|
|
68
|
+
* @param {string[]} cmdArgs
|
|
69
|
+
* @returns {Promise<{ ok: boolean }>}
|
|
70
|
+
*/
|
|
71
|
+
export async function runRecallSync(cmdArgs, {
|
|
72
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
73
|
+
stream = process.stdout,
|
|
74
|
+
isLicensedFn = isLicensed,
|
|
75
|
+
readCliTokenFn = readCliToken,
|
|
76
|
+
readQueueFn = readQueue,
|
|
77
|
+
flushQueueFn = flushQueue,
|
|
78
|
+
} = {}) {
|
|
79
|
+
if (!isLicensedFn('pro', configDir)) {
|
|
80
|
+
showUpgradePrompt('pro', 'ticketlens recall', { stream });
|
|
81
|
+
return { ok: false };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const cliToken = readCliTokenFn(configDir);
|
|
85
|
+
if (!cliToken) {
|
|
86
|
+
stream.write('Not logged in — run `ticketlens login` first.\n');
|
|
87
|
+
return { ok: false };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (readQueueFn(configDir).length === 0) {
|
|
91
|
+
stream.write('Nothing to sync.\n');
|
|
92
|
+
return { ok: true };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { flushed, remaining } = await flushQueueFn({ cliToken, configDir, warn: (s) => stream.write(s) });
|
|
96
|
+
stream.write(`Synced ${flushed} note(s). ${remaining} still pending.\n`);
|
|
97
|
+
return { ok: true };
|
|
98
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local retry queue for Recall notes whose push to the team backend failed
|
|
3
|
+
* for a transient reason (network error, timeout, or 5xx). A note is never
|
|
4
|
+
* lost — it's already safe in the local vault (recall-vault.mjs) before this
|
|
5
|
+
* module ever sees it; this only tracks the separate, best-effort intent to
|
|
6
|
+
* also sync it to the team.
|
|
7
|
+
*
|
|
8
|
+
* Retry classification happens once, at the moment the original push fails
|
|
9
|
+
* (isRetryableFailure) — 401/403/other-4xx are deliberately excluded: a
|
|
10
|
+
* stale session or a doomed payload will never succeed by retrying, and
|
|
11
|
+
* pushNote already warns the user about those synchronously.
|
|
12
|
+
*
|
|
13
|
+
* Growth is bounded two independent ways: a hard cap on entry count, and an
|
|
14
|
+
* age-based expiry keyed off firstQueuedAt (not failedAt, which refreshes on
|
|
15
|
+
* every retry attempt and would otherwise let a perpetually-failing entry
|
|
16
|
+
* live forever).
|
|
17
|
+
*
|
|
18
|
+
* enqueueNote/flushQueue do read-modify-write on the queue file with no file
|
|
19
|
+
* lock — same tradeoff already accepted by recall-pull-state.json and
|
|
20
|
+
* recall-entitlement-state.json in recall-sync.mjs. Two concurrent CLI
|
|
21
|
+
* invocations racing this file can lose one writer's update. Bounded,
|
|
22
|
+
* accepted risk: this queue is a retry-intent cache, not the source of truth
|
|
23
|
+
* (the note itself is already safe in the vault before it ever reaches here)
|
|
24
|
+
* — not worth a lock for a low-frequency, single-user CLI tool.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
30
|
+
import { writeFileAtomically } from './recall-vault.mjs';
|
|
31
|
+
import { pushNote, hashToken } from './recall-sync.mjs';
|
|
32
|
+
|
|
33
|
+
const QUEUE_FILE = 'recall-pending.json';
|
|
34
|
+
const FLUSH_STATE_FILE = 'recall-flush-state.json';
|
|
35
|
+
|
|
36
|
+
export const MAX_QUEUE_SIZE = 200;
|
|
37
|
+
export const MAX_ENTRY_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
38
|
+
export const AUTO_FLUSH_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
|
|
39
|
+
|
|
40
|
+
function queuePath(configDir) {
|
|
41
|
+
return path.join(configDir, QUEUE_FILE);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function flushStatePath(configDir) {
|
|
45
|
+
return path.join(configDir, FLUSH_STATE_FILE);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {string} configDir
|
|
50
|
+
* @returns {Array<{id: string, notePayload: object, tokenHash: string, firstQueuedAt: string, failedAt: string, attempts: number}>}
|
|
51
|
+
*/
|
|
52
|
+
export function readQueue(configDir) {
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(fs.readFileSync(queuePath(configDir), 'utf8'));
|
|
55
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeQueue(configDir, entries) {
|
|
62
|
+
writeFileAtomically(queuePath(configDir), JSON.stringify(entries));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function purgeExpired(entries, now) {
|
|
66
|
+
return entries.filter(entry => now - new Date(entry.firstQueuedAt).getTime() <= MAX_ENTRY_AGE_MS);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Decides whether a failed push is worth retrying later. Network errors and
|
|
71
|
+
* timeouts (pushNote returns no `status` for these) and 5xx responses are
|
|
72
|
+
* transient. 401 (session expired), 403 (not entitled / no team), and any
|
|
73
|
+
* other 4xx (e.g. a validation failure) are not — retrying cannot fix them
|
|
74
|
+
* without the user acting, or would just retry a payload that will never be
|
|
75
|
+
* accepted.
|
|
76
|
+
*
|
|
77
|
+
* @param {{ ok: boolean, status?: number }} result
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
export function isRetryableFailure(result) {
|
|
81
|
+
return !result.ok && (result.status === undefined || result.status >= 500);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Queues a note for later retry after a transient push failure. Purges
|
|
86
|
+
* expired entries first, then evicts the oldest entry (with a single warn)
|
|
87
|
+
* if appending would exceed MAX_QUEUE_SIZE.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} notePayload - exact wire payload passed to pushNote
|
|
90
|
+
* @param {object} opts
|
|
91
|
+
* @param {string} opts.cliToken
|
|
92
|
+
* @param {string} [opts.configDir]
|
|
93
|
+
* @param {() => number} [opts.now]
|
|
94
|
+
* @param {Function} [opts.warn]
|
|
95
|
+
*/
|
|
96
|
+
export function enqueueNote(notePayload, {
|
|
97
|
+
cliToken,
|
|
98
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
99
|
+
now = () => Date.now(),
|
|
100
|
+
warn = (s) => process.stderr.write(s),
|
|
101
|
+
} = {}) {
|
|
102
|
+
const nowMs = now();
|
|
103
|
+
let entries = purgeExpired(readQueue(configDir), nowMs);
|
|
104
|
+
|
|
105
|
+
if (entries.length >= MAX_QUEUE_SIZE) {
|
|
106
|
+
entries = entries.slice(1);
|
|
107
|
+
warn(' Recall queue full — dropped the oldest queued note to make room.\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
111
|
+
entries.push({
|
|
112
|
+
id: notePayload.external_id,
|
|
113
|
+
notePayload,
|
|
114
|
+
tokenHash: hashToken(cliToken),
|
|
115
|
+
firstQueuedAt: nowIso,
|
|
116
|
+
failedAt: nowIso,
|
|
117
|
+
attempts: 0,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
writeQueue(configDir, entries);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Attempts to push every queued entry belonging to the current account
|
|
125
|
+
* (matched by tokenHash). Entries queued under a different account are left
|
|
126
|
+
* untouched — never attempted, never evicted by this pass. Expired entries
|
|
127
|
+
* are purged first, regardless of tokenHash.
|
|
128
|
+
*
|
|
129
|
+
* @param {object} opts
|
|
130
|
+
* @param {string} opts.cliToken
|
|
131
|
+
* @param {string} [opts.configDir]
|
|
132
|
+
* @param {Function} [opts.pushNoteFn]
|
|
133
|
+
* @param {Function} [opts.warn] - defaults to silent; callers doing a visible/manual sync should pass a real one
|
|
134
|
+
* @param {() => number} [opts.now]
|
|
135
|
+
* @returns {Promise<{ flushed: number, remaining: number }>}
|
|
136
|
+
*/
|
|
137
|
+
export async function flushQueue({
|
|
138
|
+
cliToken,
|
|
139
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
140
|
+
pushNoteFn = pushNote,
|
|
141
|
+
isRetryableFailureFn = isRetryableFailure,
|
|
142
|
+
warn = () => {},
|
|
143
|
+
now = () => Date.now(),
|
|
144
|
+
} = {}) {
|
|
145
|
+
const nowMs = now();
|
|
146
|
+
const currentHash = hashToken(cliToken);
|
|
147
|
+
const entries = purgeExpired(readQueue(configDir), nowMs);
|
|
148
|
+
|
|
149
|
+
let flushed = 0;
|
|
150
|
+
const remaining = [];
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
if (entry.tokenHash !== currentHash) {
|
|
153
|
+
remaining.push(entry);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const result = await pushNoteFn(entry.notePayload, { cliToken, configDir, warn });
|
|
158
|
+
if (result.ok) {
|
|
159
|
+
flushed++;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A retry can surface a DIFFERENT failure than the one that originally
|
|
164
|
+
// queued this entry (e.g. the session expired between enqueue and this
|
|
165
|
+
// attempt) — reclassify every time rather than trusting the original
|
|
166
|
+
// enqueue decision, so a now-unrecoverable entry is dropped immediately
|
|
167
|
+
// instead of silently retrying for up to MAX_ENTRY_AGE_MS.
|
|
168
|
+
if (!isRetryableFailureFn(result)) continue;
|
|
169
|
+
|
|
170
|
+
remaining.push({ ...entry, attempts: entry.attempts + 1, failedAt: new Date(nowMs).toISOString() });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
writeQueue(configDir, remaining);
|
|
174
|
+
return { flushed, remaining: remaining.length };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readLastFlushAttemptAt(configDir) {
|
|
178
|
+
try {
|
|
179
|
+
return JSON.parse(fs.readFileSync(flushStatePath(configDir), 'utf8')).lastAttemptAt ?? null;
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function writeLastFlushAttemptAt(configDir, isoTimestamp) {
|
|
186
|
+
try {
|
|
187
|
+
fs.writeFileSync(flushStatePath(configDir), JSON.stringify({ lastAttemptAt: isoTimestamp }), 'utf8');
|
|
188
|
+
} catch {
|
|
189
|
+
// Non-fatal — worst case the next command re-checks a moment sooner than the interval intends.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Time-gated background flush, attempted from the CLI's existing
|
|
195
|
+
* Recall-touching entry points (note add's push, recall's pull) rather than
|
|
196
|
+
* on every invocation — a no-op unless the queue is non-empty AND at least
|
|
197
|
+
* AUTO_FLUSH_INTERVAL_MS has passed since the last attempt. The attempt
|
|
198
|
+
* timestamp is recorded even on failure, so a down backend can't be hammered
|
|
199
|
+
* once per command within the window.
|
|
200
|
+
*
|
|
201
|
+
* @param {object} opts
|
|
202
|
+
* @param {string} opts.cliToken
|
|
203
|
+
* @param {string} [opts.configDir]
|
|
204
|
+
* @param {() => number} [opts.now]
|
|
205
|
+
* @param {Function} [opts.flushQueueFn]
|
|
206
|
+
*/
|
|
207
|
+
export async function maybeAutoFlush({
|
|
208
|
+
cliToken,
|
|
209
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
210
|
+
now = () => Date.now(),
|
|
211
|
+
flushQueueFn = flushQueue,
|
|
212
|
+
} = {}) {
|
|
213
|
+
if (readQueue(configDir).length === 0) return;
|
|
214
|
+
|
|
215
|
+
const lastAttemptAt = readLastFlushAttemptAt(configDir);
|
|
216
|
+
const nowMs = now();
|
|
217
|
+
if (lastAttemptAt && nowMs - new Date(lastAttemptAt).getTime() < AUTO_FLUSH_INTERVAL_MS) return;
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
await flushQueueFn({ cliToken, configDir, now });
|
|
221
|
+
} catch {
|
|
222
|
+
// A down backend or a thrown network error must never crash the command
|
|
223
|
+
// that opportunistically triggered this background attempt.
|
|
224
|
+
} finally {
|
|
225
|
+
writeLastFlushAttemptAt(configDir, new Date(nowMs).toISOString());
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -56,7 +56,7 @@ function entitlementCachePath(configDir) {
|
|
|
56
56
|
// switch (e.g. login as someone else) so a stale cache never suppresses a
|
|
57
57
|
// different account's push. Found via Local Live Test: configDir alone is
|
|
58
58
|
// not a valid cache key — cli-token.json can change without configDir changing.
|
|
59
|
-
function hashToken(cliToken) {
|
|
59
|
+
export function hashToken(cliToken) {
|
|
60
60
|
return createHash('sha256').update(cliToken).digest('hex');
|
|
61
61
|
}
|
|
62
62
|
|
|
@@ -107,7 +107,11 @@ export async function pushNote(note, {
|
|
|
107
107
|
// flips the Recall grant — without this, the warning below repeats forever.
|
|
108
108
|
const checkedAt = readEntitlementCheckedAt(configDir, cliToken);
|
|
109
109
|
if (checkedAt && now() - new Date(checkedAt).getTime() < ttlMs) {
|
|
110
|
-
|
|
110
|
+
// status: 403 — this is a cached replay of the entitlement 403 below, not a
|
|
111
|
+
// fresh network outcome. Without it, isRetryableFailure's "no status means
|
|
112
|
+
// network error" rule misclassifies this as transient and queues a note
|
|
113
|
+
// that can never succeed until the owner grants entitlement.
|
|
114
|
+
return { ok: false, status: 403, skipped: true };
|
|
111
115
|
}
|
|
112
116
|
|
|
113
117
|
warnIfInsecure(apiBase(), warn);
|
|
@@ -49,7 +49,7 @@ function generateNoteId() {
|
|
|
49
49
|
return `${Date.now()}-${randomBytes(3).toString('hex')}.md`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
function writeFileAtomically(filePath, contents) {
|
|
52
|
+
export function writeFileAtomically(filePath, contents) {
|
|
53
53
|
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
54
54
|
fs.writeFileSync(tmpPath, contents, 'utf8');
|
|
55
55
|
fs.renameSync(tmpPath, filePath);
|
|
@@ -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
|