-
2026-08-17 — three ways a parked draft reached a recipient WRONG, all found in one afternoon after
a real clinical message went out damaged. Trigger: a message to a dental clinic's reviewer
arrived truncated to 38% of its length, carrying internal provenance, and studded with 20 literal
**. Root causes and fixes:
- Silent truncation, and it was injection-shaped.
bodyOnly() broke at the FIRST line matching
/^------\s*$/, but the footer it strips is always LAST — so an author's dash rule cut the
message there (1,760 of 4,635 chars). The Notes UI showed the whole note, and the sendable text
is only ever read by the human pasting it, so nothing looked wrong. The severity is WHOSE text
can trigger it: drafts quote clinician comments, patient transcripts and Drive comments verbatim,
so a dash rule written outside this machine could choose the cut point. Now findFooterStart()
scans from the END and requires BOTH signals (the rule AND a following Parked by Claude Code
line), so prose dashes are ignored and a quoted footer cannot pull the cut upwards.
- URLs cannot survive a markdown link, and the existing verify only caught it by luck. Parking
[the rules portal](https://…) and reading back yields the rules portal — URL absent, not
mangled. contentPresent() compares only the first 24 characters of the first line, so it
catches a link near the start and passes GREEN on one further in (measured both ways). new now
REFUSES markdown links and prints the inline form to use; --allow-lossy-links escapes it and
relaxes the content check, loudly.
- Markdown is not what the channel speaks.
--for whatsapp converts **bold** → *bold*,
headings → bold, and flattens [label](url) → label: url, warning on stderr about anything it
cannot map (inline code, tables). Fenced blocks are left verbatim. --copy writes the sendable
text to the clipboard, removing the hand-copy step that leaked the footer, and says so rather
than silently replacing the clipboard.
Evidence: 25 new unit tests (64 total, 0 fail), each proved in both directions, plus a mutation
check confirming the pre-fix code genuinely exhibits failures 1 and 2 — so the regression tests are
not vacuous.
-
2026-08-12 — renamed draft-hold → draft-park. Trigger: operator directive — "park" is the verb the skill's own docs and triggers already used ("park it in macOS Notes", "park the message"), while "hold" read as a queue/blocking state. Scope: skill dir skills/draft-hold/ → skills/draft-park/, shim draft-hold.sh → draft-park.sh, engine scripts/draft-hold.ts → scripts/draft-park.ts, call-site variable $DH → $DP, and every live cross-reference (plugin CLAUDE.md/README/plugin.json, marketplace.json, root + plugins CLAUDE.md, scripts/cc-plugin-root usage example, macos-font-defaults' Notes-mono cross-reference, and the operator's live ~/.claude instructions). No draft-hold alias exists — the slash command is /notes-commander:draft-park only. Deliberately NOT rewritten: CHANGELOG.md, the entries below this one, and the itp-hooks skill-plugin-root-guard forensics — those describe events that happened under the old name, and back-dating a rename into them would make the incident record wrong. Behavior: unchanged — same subcommands, same flags, same "Claude Drafts" Notes folder, so previously parked notes are still found by get/list. The one content change: the provenance footer now reads Parked by Claude Code (was Held by). Nothing parses it — bodyOnly() cuts at the ------ separator — so notes written under the old wording read back identically.
-
2026-08-05 (b) — the skill could not find its own entrypoint (exit 127). Trigger: /notes-commander:draft-hold invoked from another repo died on (eval):1: no such file or directory: /skills/draft-hold/draft-hold.sh. Root cause: this SKILL.md told the caller to use DH="$CLAUDE_PLUGIN_ROOT/skills/draft-hold/draft-hold.sh", but CLAUDE_PLUGIN_ROOT is not a shell variable. Claude Code substitutes the exact literal ${CLAUDE_PLUGIN_ROOT} (braces REQUIRED — the helper is e.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, pluginPath)) inside plugin manifests, and injects the var into hook/MCP subprocess envs — never into the Bash tool. The bare $… spelling used here is unsubstitutable on every path, so it reached zsh as an unset var, expanded to empty, and produced an absolute-looking /skills/… path — which reads like a missing file, not a missing variable. Two upstream causes made it likely: the repo's own advanced-topics.md and lifecycle-reference.md documented the rule exactly backwards ("available in skill loading, NOT in hooks" — it is the reverse), and the recovery path was equally unsound: globbing the version cache and taking the highest semver picked 23.4.1, which is marked .orphaned_at (live was 23.5.0). Fix: added scripts/cc-plugin-root (reads ~/.claude/plugins/installed_plugins.json, tolerates both registry schemas, jq with a python3 fallback, symlinked into ~/.local/bin/); this SKILL.md now resolves DH="$(cc-plugin-root notes-commander)/skills/draft-hold/draft-hold.sh"; both reference docs corrected; the stale "L3 cache strips scripts/" claim retired. Also stopped the sibling waste in this same flow: CLAUDE_CODE_SESSION_ID is already exported into the Bash env, so the provenance UUID no longer needs hunting. Evidence: resolver returns the live 23.5.0 path via both jq and python3 backends and under both registry shapes; negative cases exit 1/2 with actionable stderr; the resolved draft-hold.sh and scripts/draft-hold.ts both exist.
-
2026-08-05 (a) — every URL had to be shown naked, because the formatter escaped anchors. Trigger: staging a weekly report whose 16 PR references made the prose unreadable; the operator asked for #470-style link text and supplied a screenshot of a working link in Notes, proving Notes supports them. Root cause: escapeHtml() was applied to every prose and list line, so an <a href> could only ever render as literal <a href…. The trap that nearly ended the investigation: a probe note written with <a href> read back as <u>#470</u>, which looks exactly like "Notes stripped the link" — I nearly concluded the setter was broken. The operator's OWN hand-made link read back as <u>link text</u> too, which is what exposed the real shape: the getter is lossy, not the setter. Confirmed by decompressing ZICNOTEDATA.ZDATA from NoteStore.sqlite — the probe note's visible text was See #470 and #472. while the hrefs sat in the attribute run. A read-back-only check would have produced a false negative and no links. Fix: added pure, unit-tested renderInline() promoting [label](url) → <a href> at the three prose/list call sites (fences stay verbatim, so a fenced [x](url) renders literally); http(s)/mailto allow-list so a javascript: link can never be minted into a document a human will click. Evidence: 6 new unit tests (39 pass, 0 fail), plus a live round-trip verified against the SQLite protobuf — 16 hrefs stored, zero URLs leaked into visible prose.
-
2026-07-27 — a lead-in line silently ate the list under it. Trigger: staging a real reply draft, 解决办法有两个,你倾向哪个? immediately followed by two - bullets (no blank line) came back from get --body-only as ONE run-on line, bullets and all. Hit twice in the same session, in two different notes. Root cause: renderTextBlock() classified each blank-line-delimited paragraph by testing p[0] only — so a paragraph whose first line is prose was rendered wholly as prose, and reflowJoin() folded the following -/1. markers into it. The list was destroyed with no error. This contradicted SKILL.md, which promised list markers "each stay on their own line", and left an undocumented "you must leave a blank line before a list" rule that authors could only learn by being bitten. Fix: split each paragraph at the FIRST line matching LIST_RE — lead-in lines reflow as prose, everything from the first marker on renders per-item via the new extracted renderListItems() helper (also removes the duplicated item-grouping loop). All three shapes now work: all-prose, all-list, and lead-in-then-list. Evidence: 3 new unit tests (CJK lead-in + bullets, English lead-in + numbered list, multi-line lead-in that must still reflow before splitting); 33 pass, 0 fail.
-
2026-07-20 (b) — unified the truncation-match rule into one home (DRY). The 2026-07-20 (a) fix left the exact-then-truncated matching rule in TWO places: noteNameMatchesTitle() in notes-core.ts (used by draft-hold) AND a hand-copied AppleScript scan inside notes.ts OSA_MOVE, guarded only by a "keep the two rules in step" comment. Fix: added pure matchNoteIds(index, title) to notes-core.ts (exact ids first, else truncation-tolerant; returns every match so callers can flag ambiguity). move-note now indexes the source folder (OSA_NOTE_INDEX_BY_PATH), resolves the title in TS via matchNoteIds, and moves by id (OSA_MOVE_BY_ID); the name-matching AppleScript is deleted. draft-hold's own resolver + dedup route through matchNoteIds too. One rule, unit-tested (5 new tests, 30 total), impossible to drift. Invariant recorded in the plugin CLAUDE.md (the matchNoteIds entry).
-
2026-07-20 (a) — long titles broke read-back verify + move-note (name truncation). Trigger: parking a 66-char draft ("CPC Scanners — Procurement Intelligence & Deliverables (2026-07-20)") returned a false ✗ CONTENT-MISMATCH, and move-note "<that title>" then failed with "note not found in source folder", even though the note existed and its body was correct. Root cause: macOS Notes stores a long first-line name TRUNCATED with a trailing …, so new's read-back (body of note <title> of folder) and move-note's whose name is <title> both looked up a name that no longer equalled the title. Fix: added the pure, unit-tested noteNameMatchesTitle() + NOTES_NAME_ELLIPSIS to notes-core.ts (exact, else truncated-prefix match); new now verifies by id (OSA_GET_BY_ID) and dedups older copies via a JS-side folder index; get/sticky resolve title→id truncation-tolerantly; move-note (notes.ts) falls back to a truncated-prefix scan that captures matches by id (a live notes of src reference held past the loop throws -1728). Evidence: a 125-char-title probe now creates (clean id, no CONTENT-MISMATCH), gets its body, and move-notes across folders — all green; 5 new unit tests (25 pass total). Verified live on macOS Notes.
-
2026-07-18 (b) — migrated into notes-commander + hardened. The standalone draft-hold plugin was folded into the new notes-commander plugin as one of its skills; formatting + process wrappers moved to the shared scripts/lib/notes-core.ts engine. Added, per a web-researched audit of recent macOS AppleScript failure modes: silent-no-op detection (isNoteId on create — osascript can exit 0 yet create nothing on macOS 26), bounded retry on transient AppleEvent errors (-600/-1712), and a default-on read-back verify (entityLeaks + contentPresent). All pure helpers unit-tested (16 tests in notes-core.test.ts).
-
2026-07-18 (a) — hard-wrapped prose became forced mid-sentence breaks. A long bilingual briefing was passed with each paragraph pre-wrapped at ~100 chars; because the old bash new made each input line its own Notes paragraph (all wrapped in <tt>), the reader saw mid-sentence line breaks that did not reflow. First fix (insufficient): a "one line per paragraph" caller contract — but that only works if every caller remembers it. Real fix: reimplemented the engine as Bun/TypeScript, enforcing the formatting in code so the failure is impossible: prose blocks REFLOW (consecutive lines join; blank line = paragraph), list markers stay per-item, and only ``` fenced blocks are preserved verbatim/monospace (spaces held as ). Verified by unit tests + a live Notes round-trip.