Agent Skills › I7T5/Edmund

I7T5/Edmund

GitHub

定义Edmund Markdown编辑器的架构契约,强制在代码变更、重构或涉及TextKit 2渲染、存储同步前参考。核心包含storage与rawSource一致及仅属性渲染两大不变量,旨在解决delete-drift等深层缺陷,保障编辑体验稳定性。

29 个 Skill 104

安装全部 Skills

npx skills add I7T5/Edmund --all -g -y
更多选项

预览集合内 Skills

npx skills add I7T5/Edmund --list

集合内 Skills (29)

定义Edmund Markdown编辑器的架构契约,强制在代码变更、重构或涉及TextKit 2渲染、存储同步前参考。核心包含storage与rawSource一致及仅属性渲染两大不变量,旨在解决delete-drift等深层缺陷,保障编辑体验稳定性。
进行非平凡代码变更前 询问架构设计原因时 提议新机制或重构时 涉及NSTextView底层操作或视图同步时
.agents/skills/edmund-architecture-contract/SKILL.md
npx skills add I7T5/Edmund --skill edmund-architecture-contract -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-architecture-contract",
    "description": "The load-bearing design contract of the Edmund Markdown editor. Load BEFORE any non-trivial code change in this repo; when asking \"why is it built this way\"; before proposing a new mechanism, subsystem, or refactor; whenever you are tempted to insert\/strip display characters, use NSTextAttachment, touch NSTextView.layoutManager, store NSTextBlock\/NSTextTable attributes, or add a new overlay\/decoration; and before designing anything that syncs storage, selection, undo, or the viewport. Covers the two hard invariants (storage == rawSource; TextKit 2 only), the render pipeline, edit\/undo flow, the TextKit 2 drawing model, the read-mode contract, and the known weak points. Not for build\/run\/release mechanics, debugging triage, or live-repro drivers — see \"When NOT to use this skill\"."
}

Edmund architecture contract

Edmund is a native macOS Markdown editor with live preview: AppKit + TextKit 2, SwiftPM, macOS 14+. Two targets (Package.swift):

Target Role
EdmundCore Library: parsing, rendering, EditorTextView, all tests. Most work happens here.
edmd Executable: NSDocument app shell, Settings (SwiftUI), menus. Note: edmd is the Mach-O binary name; the app is "Edmund".

Project ambition (maintainer, 2026-07-05): product-first — "the CotEditor of Markdown editors". Bias toward polish of the editing experience over feature count. The hardest live problem class to date is delete-drift (caret/selection integrity, 6 investigation rounds); the costliest failures were delete-drift and undo/redo viewport drift. Every rule below traces to one of those scars.

Ground truth this file distills: docs/ARCHITECTURE.md (repo root). Treat that doc as authoritative if the two ever disagree, and fix this skill.

Glossary (each term defined once)

  • rawSource — the document's Markdown text, the single source of truth (EditorTextView.rawSource).
  • storage — the NSTextStorage the text view displays (EditorTextStorage, Sources/EdmundCore/TextView/EditorTextStorage.swift).
  • Block — one logical Markdown block (paragraph, heading, list run, code fence, table, quote/callout run). Model: Sources/EdmundCore/Model/Block.swift.
  • Active block — the block under the caret; it renders its raw markdown (delimiters visible/editable) while all others render styled.
  • Recompose — restyling storage from blocks (Sources/EdmundCore/TextView/EditorTextView+Composition.swift).
  • Fragment — an NSTextLayoutFragment, TextKit 2's per-paragraph layout unit. Off-screen fragments have estimated heights until laid out.
  • Overlay — an image or stroked path drawn at a character's laid-out position by the custom fragment class (see §4), replacing what NSTextAttachment would do in TextKit 1.
  • Delete-drift — the bug class where rawSource/storage/selection desync and every later edit lands the caret in the wrong place. Chronicle: docs/investigations/delete-drift-investigation.md.
  • IME composition — an input method's provisional "marked text" (hasMarkedText()), present in storage before the user commits it.

1. The two non-negotiable invariants

Break either and the editor misbehaves in subtle, delayed ways. Every design review starts here.

Invariant 1 — storage == rawSource (attribute-only rendering)

The displayed text storage is always character-identical to rawSource. Rendering only ever adds/changes attributes. Delimiters (**, `, [!note], …) are hidden, never stripped: hiddenFont (0.01pt system font, Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift:55) plus a clear foregroundColor makes them invisible without touching the string.

Rationale. Identity mapping between display offsets and raw offsets means there is no offset-translation layer — caret math, selection, undo diffs, incremental reparse, and autosave all operate on one coordinate system.

Consequences you must respect:

Consequence Why
No NSTextAttachment, ever TextKit 2 only honors attachments on U+FFFC (OBJECT REPLACEMENT CHARACTER), which rawSource never contains. Images, math, bullets, checkboxes, icons are drawn as overlays instead (§4).
No inserted display characters A synthesized <br>, bullet glyph, or padding character would desync offsets. Use attributes (.kern, paragraph styles) or overlays.
Never mutate storage while IME is composing During composition, storage holds marked text so the invariant is transiently false and didChangeText defers syncing. Styling that runs beginEditing/setAttributes/invalidateLayout mid-composition strands the marked text; the invariant then stays broken and every later edit drifts the caret. Every storage-touching styling path — including async ones scheduled before composition began — must guard !hasMarkedText().

The incident. The original delete-drift bug: an async restyle fired during IME composition, stranded the marked text, didChangeText kept bailing on its own guard, and the invariant stayed silently broken — caret drift on every subsequent edit. becomeFirstResponder now resyncs from storage as a catch-all. Full write-up: docs/investigations/delete-drift-investigation.md.

Invariant 2 — TextKit 2 only

Never touch NSTextView.layoutManager (the TextKit 1 NSLayoutManager accessor) and never store NSTextBlock/NSTextTable attributes. Either one silently and permanently reverts the view to TextKit 1 — no error, no log, just different (and wrong-for-us) layout from then on.

Rationale. All custom drawing rides NSTextLayoutFragment subclassing (§4), and viewport-based layout (only on-screen content laid out) is what makes large documents fast. Both are TextKit 2 facilities; a TK1 fallback kills them.

The tripwire. DEBUG builds observe NSTextView.willSwitchToNSLayoutManagerNotification and assertionFailure if the fallback ever triggers — textKit1FallbackTripwire(_:), Sources/EdmundCore/TextView/EditorTextView.swift:272-303. If you see that assertion, some code path you touched used a TK1 API or attribute. Find it; do not suppress the assert.

Corollary: Edit-mode tables cannot use NSTextTable. Alignment is done by distributing slack via .kern on hidden pipe glyphs — Sources/EdmundCore/Rendering/EditorTextView+TableRendering.swift.


2. Render pipeline

rawSource ──BlockParser──▶ [Block] ──styleBlock per block──▶ attributed runs in storage
                                        │
                                        └─ SyntaxHighlighter (swift-markdown walker
                                           + custom parsers: callouts, ==highlight==,
                                           wikilinks, comments, footnotes, math,
                                           backslash escapes, inline HTML tags)
Stage Where
Block splitting Sources/EdmundCore/Parsing/BlockParser.swiftparse(_:previous:), parseWithDiff(...)
Span production Sources/EdmundCore/Parsing/SyntaxHighlighter.swift + +Walker.swift / +WalkerInline.swift / +CustomParsers.swift
One-block render styleBlock(_:cursorPosition:...), Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift:151; per-feature extensions in Sources/EdmundCore/Rendering/ (Callout, Code, Image, List, ListMarker, Math, Table, WikiLinks)
Orchestration Sources/EdmundCore/TextView/EditorTextView+Composition.swift

Recompose entry points (pick the narrowest that works — a full recompose resets every fragment height to an estimate, see §6):

Function Scope Used for
recompose(cursorInRaw:) Whole document Load, indent — never for undo (§3)
recomposeDirty(_:cursorInRaw:) A set of block indices, in place The workhorse; attribute-only
recomposeIncremental(cursorInRaw:...) The block(s) the caret moved between Most cursor moves
recomposeReplacing(oldRange:with:...) One contiguous text span Undo/redo restore

Lazy styling (Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift): a large dirty set styles only the viewport synchronously; the rest is finished by the idle drain (time-budgeted main-thread slices) and scroll promotion (style blocks as they enter the viewport).

Gotcha: attribute-only changes do not re-measure geometry in TextKit 2. If a restyle changed a block's height/indent, call invalidateLayout(for:) on its range or the fragment keeps a stale frame. recomposeDirty and the idle drain already do this; any new path must too.


3. Edit flow & undo

Normal edit: shouldChangeText (records a coalesced undo snapshot) → NSTextView mutates storage → didChangeText syncs rawSource and restyles the edited block(s) (Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift, +Composition.swift). Edits capture a pendingEdit on EditorTextStorage and reparse a window, not the whole document.

Undo/redo is custom (Sources/EdmundCore/TextView/EditorTextView+Undo.swift): stacks of rawSource snapshots, bypassing NSTextView's built-in undo. Restoring diffs the snapshot against current text (textDiff(old:new:), single contiguous span) and applies it with the range-bounded recomposeReplacingnever a full recompose, because a full recompose resets every fragment to a TextKit 2 height estimate and the follow-up scroll lands wrong (this was the undo/redo viewport-drift failure). The changed text drives the viewport: hold if any of it is on-screen, else center it.

AppKit does NOT pair every storage mutation with didChangeText. Proven incident (delete-drift round 4): a drag-move of selected text dropped on no valid target deletes the dragged range via shouldChangeTextreplaceCharacters and never calls didChangeText — silently freezing rawSource/blocks; every later edit drifts the caret and autosave writes stale text. The heal: shouldChangeText schedules a next-run-loop bypass check (scheduleBypassedEditSyncCheck, +EditFlow.swift) — a pendingEdit still unconsumed by then means the closing didChangeText never came, and the editor runs the same sync itself. Breadcrumb in ~/.edmund/logs: healing storage edit that bypassed didChangeText. Never build a sync path on the assumption that didChangeText follows every edit.

Round 6 corollary: a bypassed edit also leaves TextKit 2's private selection fixup (_fixSelectionAfterChangeInCharacterRange:) queued; it fires at the next endEditing — even an attribute-only restyle — and leaps the caret blocks away, moving even a freshly set valid caret. The heal sets the caret before the sync and re-asserts it after (+EditFlow.swift). This class does not reproduce headless; see the routing in §8.


4. TextKit 2 drawing model

All custom visuals are drawn by DecoratedTextLayoutFragment (custom NSTextLayoutFragment, Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift:160), vended via the layout-manager delegate. Two custom attribute keys (same file, lines 28/32):

Attribute Level Draws Rules
.blockDecoration Paragraph Callout boxes, quote bars, table borders, thematic-break rules, code backgrounds Fragments tile vertically so a multi-line run reads as one continuous box/bar. A box's bottomPad grows the last fragment's own frame — TextKit 2 omits trailing paragraph spacing from the fragment, so padding done any other way is dead space.
.fragmentOverlay Character An image or stroked vector path at a character's laid-out position: rendered math, list bullets/checkboxes, callout header icon+name image, custom-title callout icon (path) The anchor glyph is hidden (hiddenFont + clear color) and .kern reserves the drawing's advance width — the same trick the table renderer uses.

The image-wedge constraint (open, not solved). Drawing an image overlay on a multi-line (wrapping) fragment re-triggers a layout pass that wedges the fragment to one line. Drawing a shape (stroked CGPath) does not. That is why the wrapping callout custom-title icon is a stroked path parsed from vendored Lucide geometry, never an image. Any new overlay that could share a line with wrapping text must be a shape, not an image. Full saga: docs/investigations/archives/callout-title-wrap-investigation.md.


5. Read mode contract

Read mode is a separate WKWebView, not an editor styling mode (Sources/EdmundCore/Export/). The contract: one parser, two back-ends — the same swift-markdown Document the editor parses is walked by SyntaxHighlighter.SpanCollector (→ editor attributes) and by HTMLRenderer (→ HTML), themed from the same EditorTheme via HTMLTheme, so the two renderings cannot drift. When adding a feature, implement it in both back-ends or document the divergence.

Hard properties of the web view (keep them):

  • JavaScript disabled; every asset inlined (math as high-DPI PNG data URIs — SwiftMath has no SVG path; icons as inline Lucide SVG) so it needs no file/network reach. Remote images off by default (Sources/EdmundCore/Export/ReadRenderOptions.swift).
  • Private URL schemes route navigation without JS: x-edmund-wiki: / x-edmund-link: (Sources/EdmundCore/Export/HTMLRenderer.swift:26,31).
  • Inline HTML: only the whitelist SyntaxHighlighter.htmlFormatTags (u/kbd/mark/sub/sup, Sources/EdmundCore/Parsing/SyntaxHighlighter.swift:22) renders in either mode; everything else stays escaped/color-only. A real <br> break would need to mutate storage — forbidden by Invariant 1.
  • Export/Print run the same HTML through WKWebView.printOperation (Sources/EdmundCore/Export/MarkdownPrinter.swift) for vector text.

6. Known weak points (open as of 2026-07-05)

State these plainly when designing near them; none is solved.

  1. TextKit 2 height estimates are the root of most viewport glitches: an off-screen fragment's frame (and the total document height) is an estimate until layout reaches it — scroller jumps, scroll-to-target lands wrong (a documented TK2 limitation; TextEdit shows it too). Mitigations in place, not cures: documents ≤ fullLayoutMaxLength (100k UTF-16, Sources/EdmundCore/TextView/EditorTextView.swift:80) are kept fully laid out by scheduleFullLayoutSettle() wrapped in preservingViewportAnchor (+LazyStyling.swift:121, +TypewriterScroll.swift:22); repairContentAboveOrigin() (+LazyStyling.swift:151) fixes content stranded above y=0; centerViewportOnCaret re-measures after its first scroll. Never trust an off-screen fragment's y-coordinate without laying out the span first.
  2. The image-wedge constraint (§4) applies to every new overlay.
  3. Open bugs (misc/backlog.md): callout at end-of-file renders an extra un-prefixed line in the callout color (live incremental-restyle path, not static rendering); footnotes don't render in either mode; attached images create blank space below; math doesn't render in read mode (and has wrong padding in edit mode); delete caret drift and viewport-estimate glitches remain on the ongoing list.
  4. Crash reporter endpoint is a placeholder: CrashReporter.reportingEndpoint is https://REPLACE-ME.invalid/crash (Sources/EdmundCore/Diagnostics/CrashReporter.swift:27) and the Settings ▸ Advanced toggle is commented out (Sources/edmd/Settings/AdvancedSettingsView.swift). Do not treat crash uploading as live.

7. Before you design something new — checklist

Run this before proposing any new mechanism, subsystem, or refactor:

  • Invariant 1: does it insert/strip characters, use NSTextAttachment, or mutate storage outside the shouldChangeText→didChangeText path (or during IME composition)? If yes, redesign as attributes/overlays.
  • Invariant 2: does it touch NSTextView.layoutManager or store NSTextBlock/NSTextTable? If yes, stop.
  • Sync assumptions: does it assume didChangeText follows every mutation, or that a set caret stays put across the next endEditing? Both assumptions are proven false (§3).
  • Geometry: does it read an off-screen fragment frame, or restyle without invalidateLayout(for:) when height changed? (§2, §6.)
  • Both back-ends: does a rendering feature cover Edit and Read (§5)?
  • Prior art: check ARCHITECTURE.md §14 — especially nodes-app/swift-markdown-engine, an independent AppKit+TextKit 2 live-preview engine solving the same problems — before inventing a new mechanism for an editing-experience problem.
  • Weak points (§6): does the design lean on anything listed there? Label it as such; unproven mitigations are "open/candidate", never "fixed".
  • Verification plan: unit test if headless can repro; otherwise plan a live repro (ReproScript) — do not ship a caret/IME/viewport fix on reasoning alone. Visual claims are measured from screencapture pixels, not eyeballed.

Process rules live in sibling skills, but never contradict them here: branch per fix off main; never auto-push/PR/merge; swift test green + visual verification before commit; never blanket pkill -x edmd (the maintainer's daily-driver app shares the binary name — pgrep and kill only your own PID); never request macOS Computer Access permissions.


When NOT to use this skill

You need… Use instead
Whether/how to gate a change, commit discipline, scope control edmund-change-control
A symptom → cause triage path for a bug you're seeing edmund-debugging-playbook
The blow-by-blow history of a past investigation edmund-failure-archaeology
TextKit 2 / AppKit theory beyond Edmund's specific contract textkit2-appkit-reference
Launch flags, debug bundles, defaults keys edmund-config-and-flags
Build, stale-binary cures, screencapture mechanics, environment setup edmund-build-and-env
Cutting a release, Sparkle/appcast/CI edmund-release-and-operate
Driving a live repro (ReproScript, CGEvent, log tracing) edmund-live-repro-and-diagnostics
Test-writing patterns, QA passes edmund-validation-and-qa
Docs style, ARCHITECTURE.md upkeep edmund-docs-and-writing
Positioning, comparisons, marketing claims edmund-external-positioning
The caret/selection-integrity campaign specifically edmund-caret-integrity-campaign
How to investigate an unknown (method, not facts) edmund-research-methodology / edmund-research-frontier

Provenance and maintenance

Facts verified against the repo on 2026-07-05. If a grep below stops matching, the fact drifted — update this file and cite the new location.

# Invariant 2 tripwire still present
grep -n "textKit1FallbackTripwire" Sources/EdmundCore/TextView/EditorTextView.swift
# Recompose entry points
grep -n "func recompose" Sources/EdmundCore/TextView/EditorTextView+Composition.swift
# Bypass heal
grep -n "scheduleBypassedEditSyncCheck" Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
# Custom draw attributes + fragment class
grep -n "blockDecoration\|fragmentOverlay\|class DecoratedTextLayoutFragment" Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift
# hiddenFont hiding trick
grep -n "hiddenFont" Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift
# Viewport mitigations
grep -n "fullLayoutMaxLength" Sources/EdmundCore/TextView/EditorTextView.swift
grep -n "scheduleFullLayoutSettle\|repairContentAboveOrigin" Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift
# Read-mode schemes + HTML whitelist
grep -n "wikiScheme\|linkScheme" Sources/EdmundCore/Export/HTMLRenderer.swift
grep -n "htmlFormatTags" Sources/EdmundCore/Parsing/SyntaxHighlighter.swift
# Crash-reporter placeholder (delete §6.4 once this is a real URL)
grep -n "REPLACE-ME.invalid" Sources/EdmundCore/Diagnostics/CrashReporter.swift
# Open-bug list
sed -n '/^Bugs/,/^UI\/UX/p' misc/backlog.md
Edmund仓库的构建与环境配置指南。涵盖从新机器克隆到CI镜像的环境搭建,解决构建失败、代码未生效及启动崩溃问题。包含依赖安装、核心命令解析、build-app.sh脚本流程及调试包构造。
从零开始设置开发环境或新机器初始化 构建失败或出现构建结果陈旧(代码更改未生效) 应用在启动或渲染LaTeX时崩溃 需要构建用于实时运行的调试包 验证刚构建的二进制文件
.agents/skills/edmund-build-and-env/SKILL.md
npx skills add I7T5/Edmund --skill edmund-build-and-env -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-build-and-env",
    "description": "Build, environment, and toolchain runbook for the Edmund repo (native macOS Markdown editor; AppKit + TextKit 2, SwiftPM). Load this skill when: setting up the environment from scratch (fresh clone, new machine, CI mirror); a build fails or a \"successful\" build behaves stale (change \"doesn't take\", old code runs, Build complete! but nothing changed); the app crashes on launch or the instant it renders LaTeX; you need to construct the debug bundle (EdmundDbg.app) for live runs; or BEFORE trusting any binary you just built for a visual check or repro run. Covers swift build\/test, build-app.sh anatomy (codesign sealing order, SwiftMath bundle placement), the stale-build disease and its detection, safe launch\/kill hygiene around the user's live instance, and the CI environment."
}

Edmund — build & environment runbook

All paths relative to the repo root. Facts date-stamped 2026-07-05 are volatile — re-verify per the last section.

When NOT to use this skill

You actually need Go to
The storage==rawSource / TextKit-2-only invariants, render pipeline edmund-architecture-contract
Branch/commit/PR rules, what you may touch edmund-change-control
Diagnosing a bug (not the build) edmund-debugging-playbook
ReproScript / CGEvent live-repro driving edmund-live-repro-and-diagnostics
Launch flags, defaults keys, settings edmund-config-and-flags
Cutting a release, DMG, Sparkle appcast edmund-release-and-operate
Screencapture verification method, test policy edmund-validation-and-qa

1. Environment from scratch

Requirements (verified 2026-07-05):

Tool Version Why Check
macOS 14+ Package.swift platforms .macOS(.v14) sw_vers
Xcode 16+ (full Xcode, not just CLT) swift-tools-version: 6.0; build-app.sh needs actool swift --version (local: Swift 6.0.3)
gh CLI any recent releases, PR ops gh --version
Node ≥20 releases only node --version
create-dmg npm package releases only npm install --global create-dmg

create-dmg trap: install via npm, NOT Homebrew. brew install create-dmg is a different tool with an incompatible CLI. Not needed for dev work — only for cutting releases (see edmund-release-and-operate).

Dependencies are fetched by SPM on first build — nothing to install by hand (verified against Package.swift / Package.resolved, 2026-07-05):

  • swift-markdown ≥0.5.0 (CommonMark/GFM parsing; pulls swift-cmark transitively)
  • SwiftMath ≥1.7.0 (LaTeX rendering — its resource bundle is a launch-crash landmine, §3)
  • Sparkle ≥2.6.0 (auto-update — its framework is a dyld-abort landmine, §4)

Two SPM targets: EdmundCore (library + all tests; most work happens here) and edmd (the app shell executable). The binary is named edmd even though the app presents as "Edmund" — deliberate, see the comment in Package.swift.

2. Core commands

swift build                    # debug build of both targets
swift test                     # full suite: ~750+ tests, ~10s (2026-07-05)
swift test --filter Callout    # one suite
./scripts/build-app.sh         # release build → build/Edmund.app

swift test also runs automatically as a Stop hook after code-touching turns.

3. What build-app.sh actually does (and why the order matters)

scripts/build-app.shbuild/Edmund.app. Steps, in order:

  1. swift build -c release
  2. Assemble build/Edmund.app/Contents/{MacOS,Resources}; copy .build/release/edmd, Info.plist, Resources/AppIcon.icns.
  3. Compile Resources/Assets.xcassets with actool (falls back to /Applications/Xcode.app/.../actool if xcode-select points at the CLT).
  4. Embed Sparkle.framework into Contents/Frameworks/ (found under .build/; SwiftPM links Sparkle but never copies the framework — without it the updater crashes on first check) and install_name_tool -add_rpath "@executable_path/../Frameworks" so @rpath resolves post-install.
  5. Codesign inside-out: Sparkle.framework first (nested XPC helpers must be signed before macOS will launch them), then the whole .app (ad-hoc, --deep, identifier com.i7t5.edmd). Sealing the bundle — not just the binary — is what Sparkle's update validator requires.
  6. Only after sealing: copy .build/release/*.bundle (SwiftMath's math fonts) into the .app root.

Why step 6 is last and at the root — two constraints collide:

  • codesign refuses to seal a bundle with any extra item at the .app root ("unsealed contents present in the bundle root"), so the seal must happen while the root holds only Contents/.
  • SwiftMath's generated Bundle.module accessor hardcodes Bundle.main.bundleURL — the .app root — with only a hardcoded absolute .build path as fallback. So the bundle must sit at the root.

Resolution: seal first, copy after. The one unsealed root item makes codesign --verify and --strict complain, but Sparkle's actual check is non-strict and tolerates it (verified end-to-end; details in docs/ARCHITECTURE.md §8).

Missing SwiftMath bundle = instant crash the moment the app renders any LaTeX. App launches fine, opens documents fine, dies on the first math block. If you see that crash, check ls build/Edmund.app/*.bundle first.

4. Debug bundle fast path (EdmundDbg.app)

For live runs of a debug build, skip build-app.sh and hand-assemble (from docs/dev-guides/live-repro-guide.md §4):

swift build
mkdir -p build/EdmundDbg.app/Contents/MacOS
cp Info.plist build/EdmundDbg.app/Contents/
cp .build/arm64-apple-macosx/debug/edmd build/EdmundDbg.app/Contents/MacOS/
cp -R .build/arm64-apple-macosx/debug/Sparkle.framework build/EdmundDbg.app/Contents/MacOS/
  • Sparkle.framework must sit next to the binary — dyld aborts without it.
  • A bare .build/debug/edmd runs but never creates a window. It needs the bundle (Info.plist) around it.
  • Launch by direct exec of the bundle binary, never open -a:
build/EdmundDbg.app/Contents/MacOS/edmd /path/to/test.md \
  -ApplePersistenceIgnoreState YES &

LaunchServices (open -a) can silently run a stale cached/translocated copy — you'd be executing last hour's code. Direct exec runs exactly the binary you just copied. -ApplePersistenceIgnoreState YES stops state restoration from reopening previous (possibly mutated) documents.

  • Recreate the test document fresh before every run — autosave mutates it.

5. THE STALE BUILD DISEASE

The single most expensive trap in this repo: it has produced entire wrong debugging conclusions ("my fix doesn't work" when the fix was never in the binary).

Symptom: swift build prints Build complete! having compiled a changed file but not relinked edmd. The app then runs old code. Release builds (swift build -c release / build-app.sh) reuse stale objects too.

Detection — before trusting ANY binary you just built:

# 1. Grep for a LONG string literal unique to your new code:
strings .build/arm64-apple-macosx/debug/edmd | grep 'your long unique literal'
# 2. Hash before/after the build:
shasum .build/arm64-apple-macosx/debug/edmd

Literal length matters: string literals ≤15 bytes are stored inline in the Mach-O on arm64 and never appear in strings output. A short probe literal gives a false "stale" verdict. Use a long one (a distinctive log message works well).

Cure:

swift package clean          # first resort
rm -rf .build                # visual change "doesn't take" → nuke it all

Never hand-delete .build/…/edmd.build/ — that corrupts SwiftPM's output-file-map and wedges the target until a full clean anyway.

6. Running for visual checks — launch/kill hygiene

The user's daily-driver app has the same binary name (edmd). A blanket pkill -x edmd kills their live session. Always, in order:

# 1. Who is running, and since when?
pgrep -lx edmd
ps -o lstart=,command= -p <pid>
# 2. Kill ONLY your own PID — or, if you launched the debug bundle:
pkill -f EdmundDbg

Other run gotchas:

  • open Edmund.app foregrounds a running instance instead of relaunching — you'll be looking at the old binary. Kill your instance first or direct-exec the binary.
  • Always pass -ApplePersistenceIgnoreState YES (see §4).
  • After many rapid launch/kill cycles the window server can glitch (tiny windows, broken state restoration): rm -rf ~/Library/"Saved Application State"/com.i7t5.edmund.savedState and relaunch.
  • Verification method (window-id screencapture, offscreen render fallback): edmund-validation-and-qa and docs/ARCHITECTURE.md §8.

7. CI environment

.github/workflows/ci.yml (verified 2026-07-05): runs swift test on macos-14 with latest-stable Xcode (Swift 6.0 needs Xcode 16+), triggered on PRs and pushes to main.

  • SPM cache: .build is cached keyed on spm-v2-${{ runner.os }}-${{ hashFiles('Package.resolved') }}. The v2 token exists because the repo rename mdEdmund changed the checkout path and invalidated absolute paths baked into the cached module cache — bump the token to discard a poisoned cache.
  • Concurrency: cancel-in-progress: true per branch/PR — private-repo macOS minutes bill at 10x, so superseded commits' runs are cancelled.
  • Release pipeline (release.yml, tag-triggered) is a separate beast: edmund-release-and-operate / docs/ARCHITECTURE.md §13.

8. Checklists

Fresh clone to green

  • sw_vers — macOS 14+; swift --version — Swift 6.x (Xcode 16+)
  • git clone + cd Edmund
  • swift build — SPM fetches swift-markdown, SwiftMath, Sparkle
  • swift test — ~750+ tests green in ~10s
  • Read docs/ARCHITECTURE.md (mandated by AGENTS.md before non-trivial work)
  • Visual work planned? ./scripts/build-app.sh, confirm build/Edmund.app exists and ls build/Edmund.app/*.bundle shows the SwiftMath bundle
  • Releases planned? node --version ≥20, npm install --global create-dmg

Before trusting any run

  • Binary is fresh: strings <binary> | grep '<long unique literal>' and/or shasum changed since the edit (§5)
  • pgrep -lx edmd — user's live instance identified; you will kill only your own PID (§6)
  • Launched by direct exec, not open -a (§4)
  • -ApplePersistenceIgnoreState YES passed
  • Test document recreated fresh (autosave mutated the last one)
  • Debug bundle: Sparkle.framework sits next to the binary
  • Release bundle: SwiftMath *.bundle at the .app root (or the first LaTeX render crashes)

Provenance and maintenance

Every claim above was read from the files below on 2026-07-05. Re-verify:

  • Toolchain/deps: cat Package.swift (tools-version, platforms, dep versions); grep identity Package.resolved
  • Build anatomy: cat scripts/build-app.sh (step order, sealing comments)
  • Test count/time: swift test 2>&1 | tail -3
  • Debug bundle recipe: docs/dev-guides/live-repro-guide.md §4
  • Stale-build disease, launch gotchas: docs/ARCHITECTURE.md §8 ("Stale release builds", "open Edmund.app", savedState)
  • CI facts: cat .github/workflows/ci.yml (cache key comment, concurrency)
  • create-dmg quirks: docs/ARCHITECTURE.md §8 ("create-dmg — npm only")

If a command here disagrees with those files, the files win — update this skill.

针对Edmund编辑器中删除漂移(光标/选区完整性)问题的决策型调试活动。用于修复文本编辑后光标错位、存储不同步等复杂问题。流程涵盖日志分类、排除已知问题、捕获证据、构建确定性复现脚本、验证解决方案并安全发布,严禁仅凭推理修复。
删除或输入后光标位置错误 日志中出现 LEN-MISMATCH 警告 文本编辑导致存储与内容不同步 自动保存写入陈旧内容
.agents/skills/edmund-caret-integrity-campaign/SKILL.md
npx skills add I7T5/Edmund --skill edmund-caret-integrity-campaign -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-caret-integrity-campaign",
    "description": "The executable, decision-gated campaign for Edmund's hardest live problem: the delete-drift class (caret\/selection integrity in the live NSTextView \/ TextKit 2 \/ input-context layer). Load when the caret or selection lands in the wrong place after a delete\/type\/IME\/drag interaction, when text edits corrupt or desync, when autosave writes stale content, or when logs show ⚠︎LEN-MISMATCH or the \"healing storage edit that bypassed didChangeText\" breadcrumb. Runs round 7+ at the retiring maintainer's standard: classify, fence off six settled battles, capture evidence, build a deterministic repro, pick from a ranked solution menu with proof obligations, then validate and promote through change control. Not for viewport\/scroll drift (that's edmund-debugging-playbook → viewport docs) unless the caret itself moves."
}

Caret-integrity campaign (the delete-drift class)

Six rounds are settled; new rounds are expected. This class does not reproduce in headless tests — the harness runs AppKit's deferred machinery synchronously, so a green unit test proves nothing here. Success is measured by PASS/FAIL grep and byte counts, never by eye or by reasoning.

Verified 2026-07-05 against docs/investigations/delete-drift-investigation.md (456 lines) and the code. Read that doc fully before a deep dive.


QUICK CARD (the whole loop)

0. CLASSIFY   grep logs for the 4 signatures. Not this class? → debugging-playbook.
1. FENCE      check the 6 settled battles + guards still hold. Don't re-fight them.
2. EVIDENCE   run with verbose diags; find FIRST bad line; walk BACKWARDS;
              traceSelectionOrigin names who moved the caret; rebuild the doc.
3. REPRO      ReproScript: needles not offsets; real events; replicate internal
              call sequences verbatim. Freeze a script whose logsel/assertcaret
              DISCRIMINATES broken vs current build. No repro → hypothesis wrong.
4. SOLVE      rank candidates (missing guard < extend heal < reorder fixup <
              structural). Each states what it must PROVE.
5. PROMOTE    fix flips frozen repro to PASS same run → soak 4–5 cycles,
              byte-identical rawLen → swift test green → add test (locks headless
              contract even if non-discriminating) + keep .repro → update the
              investigation doc → branch/commit via change-control. Never ship on
              reasoning alone.

PHASE 0 — Classify: is it actually this class?

Grep ~/.edmund/logs/edmund-<date>.log (run the app with -settings.general.diagnosticLogging YES -settings.advanced.verboseEditorDiagnostics YES):

Signature Meaning
healing storage edit that bypassed didChangeText a bypass fired (round-4 class)
persisting ⚠︎LEN-MISMATCH (not just transient between shouldChangeText→synced) storage/rawSource desync stuck
selectionDidChange with up=Y at a surprising position selection moved mid-recompose (round-6 class)
a shouldChangeText with no synced/SKIPPED/DEFERRED after it a bypassed didChangeText

scripts/grep-trace.sh in edmund-live-repro-and-diagnostics surfaces all four at once.

If none match and the symptom is scroll/viewport-shaped (jump, wrong landing, can't-scroll-up) with the caret not leaping → this is the viewport class, not caret integrity → edmund-debugging-playbook + docs/investigations/viewport-glitch-investigation.md. Do not run this campaign on a viewport bug.


PHASE 1 — Fence off the six settled battles

Confirm each shipped guard still holds before hypothesizing a new mechanism. The most likely round-7 shape is a new code path that lacks an existing guard, not a new mechanism.

Round Mechanism (settled) The shipped guard — confirm it still covers your path
1–3 IME marked-text stranding: styling that runs beginEditing/setAttributes/invalidateLayout while hasMarkedText() strands the composition; didChangeText then bails forever and every edit drifts every storage-touching styling path guards !hasMarkedText() — including async ones scheduled before composition began (+SelectionTracking caret-move restyle). becomeFirstResponder resyncs as catch-all
4 Drag-move bypass: a drag-move whose drop has no valid target deletes via shouldChangeTextreplaceCharacters with no didChangeText; rawSource/blocks freeze; autosave writes stale text scheduleBypassedEditSyncCheck (+EditFlow.swift): a next-run-loop check finds an unconsumed storage pendingEdit and runs the sync (breadcrumb above)
5 Heal leaped the caret: the round-4 heal ran against a stale selection and moved the caret heal collapses/derives the caret from the pendingEdit hull before syncing
6 Queued selection fixup: TK2's _fixSelectionAfterChangeInCharacterRange stays queued after a bypass and fires at the next endEditing (even an attribute-only restyle), remapping the stale selection and leaping even a freshly set, valid caret heal sets the caret from the pendingEdit hull before the sync AND re-asserts it after (+EditFlow.swift)
7 Same queued fixup on the NORMAL edit path (not the heal): armed by a cross-block caret move (schedules the async caret-move restyle), the fixup fires during syncRawSourceFromDisplayrecomposeDirty's endEditing on an ordinary keystroke and leaps the caret to the block boundary; the normal path styles settingSelection=false and never re-asserts, so it persists syncRawSourceFromDisplay captures the pendingEdit-hull caret before consumePendingEdit, re-asserts it after recomposeDirty if the fixup moved it (+EditFlow.swift; breadcrumb re-asserting caret after fixup leap (normal path)) — fix not yet confirmed by a deterministic repro, live-verifiable via the breadcrumb

Confirm the guards exist:

grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/
grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift

FENCED WRONG PATHS (each proven dead — do not retry)

  • Fixing by nudging the caret at symptom time. The symptom is armed seconds-to-minutes earlier (round 6: drift at 22:13 armed at 22:11:57). You'd patch the wrong instant.
  • Trusting a headless regression test. The round-6 test passes with and without the fix. Headless runs the fixup synchronously.
  • Shipping on reasoning alone. Rounds 1–5 all came back. Round 6's first fix candidate "worked by reasoning" and failed in the repro within a minute.
  • Assuming didChangeText pairs with every mutation. AppKit violates this (round 4).
  • Mutating storage mid-composition. That is the original bug (rounds 1–3).
  • Expecting a scripted keystroke replay to arm round 7. Round 7 was replayed faithfully from a reconstructed t0 (every keystroke + capped pauses through the whole drift window) and it did not produce the block-end leap. Programmatic caret moves (clickoff) and imprecise synthetic clicks (realclickoff — lands off-by-a-few, diverges, crashes) do not fully arm it. The arming needs real mouse clicks at real positions and probably the real session's two-document window switching (becomeFirstResponder resync is the prime suspect). Round-8 lead: two open docs + real clicks, or instrument becomeFirstResponder/the caret-move restyle to catch the next live occurrence. Compressed replays also coalesce a bypass with the next keystroke into one pendingEdit hull (never happens live — the heal runs first), producing off-by-one breadcrumb noise; don't trust it as a repro.

PHASE 2 — Capture evidence

  1. Launch with verbose diagnostics (debug bundle; file arg is argv[1]):
    build/EdmundDbg.app/Contents/MacOS/edmd DOC.md \
      -settings.general.diagnosticLogging YES \
      -settings.advanced.verboseEditorDiagnostics YES \
      -ApplePersistenceIgnoreState YES
    
  2. Decode the trace fields: sel/active/marked/up/undo/blocks/storLen/rawLen. up=Y = event arrived mid-recompose (suspicious). Healthy ordering: shouldChangeTextselectionDidChange (up=N)synced; a transient ⚠︎LEN-MISMATCH between those is normal, a persisting one is not.
  3. Find the FIRST bad line, then walk BACKWARDS. The visible symptom is often the second half of a two-part mechanism.
  4. traceSelectionOrigin logs the call stack of whoever moved the selection mid-recompose — this is what named _fixSelectionAfterChange in round 6. If the caret moves and you don't know who moved it, this answers it in one run.
  5. Reconstruct the document. Wrapped-paragraph geometry and block kinds matter — repro against a lookalike, never "hello world".

Gate: you have a candidate trigger hypothesis + the first-bad-line timestamp. Otherwise instrument (add a Log.shouldTrace breadcrumb — one call stack or state dump beats ten speculative fixes) and wait for the next occurrence.


PHASE 3 — Build the deterministic repro

Use the in-process ReproScript driver (Sources/edmd/App/ReproScript.swift, DEBUG only; full guide in edmund-live-repro-and-diagnostics). Commands: sleep / caret / type / backspace / bypassdelete / assertcaret / logsel.

Worked example — the round-6 minimal repro (the first deterministic repro in six rounds):

sleep 2000
bypassdelete Sizemore,
sleep 800
logsel            # broken build: 321   fixed build: 290
backspace 2
logsel

The deciding output was logsel 321 → 290 with the fix, every run, window not even visible.

Rules that make repros survive editing:

  • Needles, not offsets — offsets go stale the instant the script edits.
  • Real events, not method shortcutsinsertText("") skips deleteBackward's selection machinery, exactly where round 6 lived.
  • Replicate AppKit-internal call sequences verbatimbypassdelete does shouldChangeTextreplaceCharacters, no didChangeText, not an approximation. For a new internal path, pin its real sequence from a traceSelectionOrigin stack first, then replay it (extend ReproScript ~10 lines).

Gate: a frozen script (exact commands + document) whose logsel / assertcaret PASS/FAIL output discriminates the broken build from the current one.

No repro after honest attempts? The hypothesis is wrong, or fidelity is too low — a mouse-only path (real drag-select/drag-move) needs the CGEvent driver (edmund-live-repro-and-diagnostics §4). Loop back to Phase 2.


PHASE 4 — Solution menu (ranked; each with a proof obligation)

Pick by the observation pattern. Cheaper is higher.

  1. Add a missing guard on an existing invariant (cheapest, most likely for a new round). Guard inventory: !hasMarkedText() on the offending styling path; bypass-check scheduling on a new mutation entry point; caret re-assertion after a restyle. Proof: the frozen repro flips to PASS and no other .repro regresses. Selects when: a specific new code path shows the round-1/4/6 signature that its siblings already guard.
  2. Extend the heal to a new bypass source. Proof: first add a ReproScript command that replays the new source's exact call sequence, show it reproduces the freeze, then show the extended heal fixes it. Selects when: trace shows a shouldChangeText with no close-out from a path other than drag-move.
  3. Intercept/reorder the queued fixup. Proof obligation: explain when TK2 queues and fires _fixSelectionAfterChange and show the reorder does not fight AppKit's machinery (no oscillation, no double-move). Selects when: traceSelectionOrigin names the fixup and the caret is valid before it fires. Higher risk — you are stepping into private AppKit ordering.
  4. Structural: make rawSource sync independent of didChangeText pairing (e.g. a storage-version counter that reconciles regardless of which callbacks fired). Biggest change; candidate, unproven — route through edmund-research-frontier (frontier item "caret integrity by construction"). Proof: all historical .repro scripts + a randomized bypass-fuzzer soak stay green with the callback-pairing assumption removed.

PHASE 5 — Validate and promote

  1. Fix candidate flips the frozen repro to PASS within the same run. (If it only "works by reasoning," it is not done — round history.)
  2. Soak (edmund-live-repro-and-diagnostics §3): one script, one run, 4–5 trigger cycles at different document positions with ordinary editing between, assertcaret after every predictable step. Green across all cycles and byte-identical final rawLen across repeated runs = deterministic.
  3. swift test green (also the Stop hook).
  4. Add a regression test even if it can't discriminate the live mechanism — it locks the headless contract (assert rawSource == string; see the BypassedEditSyncTests / MarkedTextDesyncTests family). Keep the .repro script in the repo for the live half.
  5. Update docs/investigations/delete-drift-investigation.md with the new round (symptom → root cause → repro recipe → fix → status), and docs/ARCHITECTURE.md §8 if an invariant changed — same PR.
  6. Route through edmund-change-control: branch fix/… off main, small commits, never auto-push/PR/merge. (No screencapture unless the fix also draws.)

Never promote a fix that only "works by reasoning."


When NOT to use this skill

  • Viewport/scroll drift where the caret itself does not leap → edmund-debugging-playbook + docs/investigations/viewport-glitch-investigation.md.
  • Just need the repro driver mechanics / trace decoding → edmund-live-repro-and-diagnostics.
  • The AppKit theory behind the mechanisms → textkit2-appkit-reference.
  • The historical record of prior rounds → edmund-failure-archaeology.
  • The structural (round-∞) redesign as a research project → edmund-research-frontier + edmund-research-methodology.

Provenance and maintenance

Verified 2026-07-05 against docs/investigations/delete-drift-investigation.md, Sources/edmd/App/ReproScript.swift, and Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift.

grep -nE '^## Round' docs/investigations/delete-drift-investigation.md          # round chronicle
grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/
grep -oiE '"(bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u
# round-6 discriminating numbers (321 broken / 290 fixed):
grep -n '321\|290' docs/investigations/delete-drift-investigation.md

When round 7 lands, add its row to Phase 1 and its dead ends to the fenced list.

定义Edmund仓库变更分类、审查门禁及验证规则。涵盖文档、代码逻辑、视觉绘制、编辑流程及发布等五类变更,明确各类需执行的测试、截图或复现要求,确保历史教训转化为强制规范,防止回归缺陷。
准备修改仓库代码前 决定是否需要测试或视觉检查时 不确定变更是否需维护者介入时
.agents/skills/edmund-change-control/SKILL.md
npx skills add I7T5/Edmund --skill edmund-change-control -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-change-control",
    "description": "How changes are classified, gated, and reviewed in the Edmund repo. Load at the START of any task that will modify the repo; before committing, branching, or proposing a merge or release; when deciding whether a change needs a test, a screencapture visual check, or a live repro; when unsure what requires explicitly asking the maintainer (push, PR, merge, release, deleting uncommitted work, touching test-files\/, adding dependencies). Contains the non-negotiable rules with the historical incident behind each one."
}

Edmund change control

Last verified: 2026-07-05, against main @ fe8a1f5 (release 0.1.3).

This is the gatekeeping doc: what class of change you are making, which gate it must pass, and the rules that are never traded away. The rules exist because each was paid for — the incident column is not decoration.

0. Before you start (task setup)

[ ] Read docs/ARCHITECTURE.md before any non-trivial change (its own rule)
[ ] git status — note any uncommitted work; never clobber it
[ ] Not on main? Fine. On main? Branch NOW, before the first edit
[ ] Classify the change (§1) so you know the gate before you write code
[ ] Edit-pipeline / selection work? Plan the live repro FIRST — if you can't
    reproduce the bug, you can't prove the fix (§3, last row)

1. Classify the change, apply the gate

Classify FIRST, before writing code. The class decides the verification bar.

Class Examples Gate before commit
Docs-only ARCHITECTURE.md, README, docs/*.md, comments None beyond review. swift test still runs as a Stop hook; ignore no failures it surfaces.
Code (logic) change Parser, block model, helpers, non-drawing refactor swift test green. New behavior or bug fix → add a test that fails without the change.
Visually-drawing change Anything in Rendering/, overlays, decorations, padding, fonts, layout fragments All of the above, PLUS build the app and screencapture the result (window-by-id method — see edmund-live-repro-and-diagnostics). Headless layout is not proof for anything that draws.
Edit-pipeline / selection behavior +EditFlow, +Composition, +SelectionTracking, +Undo, caret, IME, drag, viewport timing All of the above, PLUS a live repro or soak script (-debug.reproScript, see edmund-caret-integrity-campaign and docs/dev-guides/live-repro-guide.md). Headless tests cannot exercise deferred AppKit machinery — the queued selection fixup, drag paths, IME. Rounds 1–5 of delete-drift shipped on tests + reasoning; all recurred.
Release Version bump, tag, appcast Run misc/before-you-release.md top to bottom, then misc/how-to-release.md. See edmund-release-and-operate. Never start a release without being asked.

Notes on the gates:

  • swift test (~750+ tests, ~10s) also runs automatically as a Stop hook (.Codex/settings.json: swift test 2>&1 | tail -5) at the end of every turn. That is a safety net, not the gate — run it yourself before committing so the failure is yours to see, not the hook's.
  • A change can be in multiple classes. Apply the union of gates. A caret fix that also moves a decoration needs test + screencapture + live repro.
  • "Draws" is broad: padding, insets, colors, wrapping, fragment frames. If a human could see the diff, screenshot it.

Classification edge cases that have gone wrong before:

  • "It's just an attribute change" is NOT automatically the code-logic class. If the attributes change measured geometry (font size, paragraph spacing, hidden-delimiter width), it draws AND it needs invalidateLayout(for:) — see §3.
  • "It's just a restyle helper" that runs beginEditing/setAttributes on storage is edit-pipeline class if it can fire during IME composition or after a bypassed edit. When in doubt, grep for hasMarkedText guards on the sibling paths and match them.
  • A test-only change is docs-class for gating purposes (nothing to screenshot), but the Stop hook still must pass — a broken test is a broken commit.
  • Release-adjacent edits (Info.plist versions, CHANGELOG.md, appcast.xml, scripts/release.sh, .github/workflows/) are release class even when tiny. The v0.1.0→0.1.1 Sparkle failure came from the build script's signing step, not from app code.

2. Git discipline

From AGENTS.md + ARCHITECTURE §12 + the repo's own history (git branch -a).

  • Branch off main for every fix. Never commit straight to main. One feature/fix per branch.
  • Branch prefixes actually in use (verified): fix/, feat/, feature/, docs/, chore/, uiux/, ui/, ux/, markdown/, bug/, refactor/, ci/, release/. Prefer fix/, feat/, docs/, chore/ for new work; uiux/ for visual polish; markdown/ for syntax-feature work.
  • Small, logical commits; commit frequently. A commit that mixes the fix with a drive-by refactor is two commits done wrong.
  • NEVER auto-push, open a PR, or merge. Only when the maintainer explicitly asks. No exceptions for "it's just docs."
  • Never discard uncommitted changes. No git checkout -- ., git reset --hard, git clean on a dirty tree without explicit permission.
  • Commit messages follow the observed style: fix(editor): …, docs: …, ui: …, chore: … — short imperative subject.

3. The non-negotiables

Each rule was established by an incident. Verify against the cited doc before arguing an exception.

Rule Rationale Incident
Text storage always equals rawSource; rendering is attribute-only. Never insert/delete display characters — hide delimiters, never strip them. Display offset == raw offset (identity mapping) is what every selection, sync, and heal path assumes. Break it and every later edit drifts. The delete-drift saga: six rounds over months, each recurrence traced to storage/rawSource divergence in some path. docs/investigations/delete-drift-investigation.md.
TextKit 2 only. Never touch NSTextView.layoutManager; never store NSTextBlock/NSTextTable attributes. Either one silently and permanently reverts the view to TextKit 1. A DEBUG tripwire asserts if TK1 engages — heed it. ARCHITECTURE §2; the tripwire exists because the reversion is otherwise invisible.
No NSTextAttachment. Images/icons are drawn as overlays. TK2 only honors attachments on U+FFFC, which rawSource never contains (see rule 1). ARCHITECTURE §2, §5.
Never draw images on wrapping (multi-line) fragments; use stroked CGPaths instead. A TK2 image on a wrapping fragment wedges layout — collapses the fragment to one line. Shapes don't trigger it. The callout custom-title icon: docs/investigations/archives/callout-title-wrap-investigation.md; fix in ae61644 (stroked path, not image).
Every storage-touching styling path guards !hasMarkedText() — including async paths scheduled before composition began. Mutating storage mid-IME-composition strands the marked text; didChangeText then bails forever on its own guard and every later edit drifts. Delete-drift rounds 1–2 (IME stranding cascade). docs/investigations/delete-drift-investigation.md.
Never assume didChangeText follows every edit. Sync paths must survive a bypassed edit. AppKit's drag-move-to-nowhere deletes via shouldChangeTextreplaceCharacters and never calls didChangeText, silently freezing rawSource. The heal (scheduleBypassedEditSyncCheck in +EditFlow) exists for this. Delete-drift round 4 (9f99795); rounds 5–6 hardened the heal itself.
Attribute-only restyles that change geometry must invalidateLayout(for:) the range. TK2 does not re-measure on attribute change; the fragment keeps a stale frame — empty bands, clipped lines. ARCHITECTURE §8; recomposeDirty and the idle drain already do this — new paths must too.
Undo restore is diff-based recomposeReplacing — never a full recompose. Full recompose resets every fragment to a TK2 height estimate; the follow-up scroll lands wrong and the viewport drifts. Undo/redo viewport drift — one of the costliest failures here. Fixed in 5bb2b40 (fix(undo): select + center the changed text; diff-based snapshot restore).
Never blanket pkill -x edmd. pgrep -x edmd first, check start times, kill only the PID you launched. The maintainer's daily-driver app shares the binary name. A blanket pkill kills their editor with their work in it. (ARCHITECTURE §1's pkill -x edmd shorthand predates this rule — don't copy it.) Established after the maintainer's own instance was killed during a debugging session.
Never request macOS Computer Access (Screen Recording / Accessibility). Both are already granted to the tools you use. Requesting again re-prompts the maintainer and can wedge TCC state. AGENTS.md "Environment"; the -debug.reproScript driver exists precisely so repros need no new TCC grants.
Visual judgments ("balance the padding", "is it centered") are MEASURED from screencapture pixels, not eyeballed. Eyeballed "looks right" repeatedly shipped asymmetric spacing. Crop the window, count pixels, state the numbers. Maintainer's explicit rule from UI-polish rounds (status-bar / table-padding branches).
Files in test-files/ are the maintainer's manual test corpus. Never rewrite them for automation; test-files/todo.md especially is owner-edited. They encode the maintainer's by-hand regression walk. Automation churn destroys that. Create your own fixtures in a scratch dir or Tests/. Standing maintainer rule.
Never ship a fix for a live-input-layer bug (caret, IME, drag, selection timing) on reasoning alone. A frozen repro script must falsify the bug before and confirm the fix after. This bug class does not reproduce headless (the test harness runs TK2's queued fixup synchronously). Reasoning about deferred AppKit machinery has a ~0% shipping record here. Delete-drift rounds 1–5 each shipped a plausible fix; each came back. Round 6 finally held because the ReproScript driver reproduced the drift deterministically first. docs/dev-guides/live-repro-guide.md.

The two incidents that shaped this table

Worth knowing as stories, because the rules read as pedantry until you see the cost:

  • Delete-drift (six rounds). The hardest live problem this repo has had. A caret that drifted after deletes. Round 1 blamed IME stranding — plausible fix, shipped, recurred. Round 2 disabled remaining marked-text sources — recurred. Round 3 stopped guessing and built diagnostics (selection tracing, event logs). Round 4's diagnostics caught a drag-move deleting storage with no didChangeText — the heal was born. Round 5: the heal itself leaped the caret via a stale selection. Round 6 found the actual drift mechanism — TextKit 2's queued _fixSelectionAfterChange firing at the next endEditing — and held only because the ReproScript driver could replay the exact keystroke sequence deterministically. Five shipped fixes failed; the one preceded by a frozen repro stuck. That asymmetry IS the change-control policy for this bug class.
  • Undo/redo viewport drift. Undo restored a snapshot via full recompose, which reset every fragment to a TK2 height estimate; the follow-up scroll-to-caret then landed wrong and the viewport jumped. The fix (5bb2b40) diffs the snapshot against current text and applies only the changed span with recomposeReplacing. Moral: in TK2, layout state is part of the document state you must preserve — "re-render everything" is never the safe fallback here, it is the bug.

4. Review expectations

  • ARCHITECTURE.md is updated in the same PR whenever you learn something non-obvious or change an invariant. The doc's own header demands this; the gotchas in §8 all arrived this way.
  • Quirks are documented as comments at the code site — the edge case, the workaround, the why. Not in commit messages, not in AGENTS.md.
  • New known issues go in ARCHITECTURE §9 with a one-line repro and a pointer to any deeper write-up in docs/.
  • Big investigations (multi-round bugs) get a docs/*-investigation.md chronicle — see edmund-failure-archaeology for the pattern.

5. Pre-commit checklist (copy-paste)

The workflow that worked (ARCHITECTURE §12 + AGENTS.md). Run it verbatim:

[ ] swift test — all green (also enforced by the Stop hook; don't rely on it)
[ ] New behavior / bug fix → a test exists that fails without the change
[ ] Draws anything? → build app, screencapture window-by-id, look at the PNG
[ ] Edit-pipeline / selection change? → live repro or soak script passed
[ ] On a branch off main (fix/…, feat/…, docs/…, chore/…), NOT on main
[ ] Diff touches only what the task needs; style matches surroundings
[ ] Learned something non-obvious? → ARCHITECTURE.md updated in this change
[ ] Quirk introduced/found? → comment at the code site
[ ] Commit is small and logical; message matches repo style (fix(scope): …)
[ ] NOT pushing, NOT opening a PR, NOT merging (unless explicitly asked)

6. Ask the maintainer first — always

Never do these unprompted; ask and wait for an explicit yes:

Action Why it's gated
git push, opening a PR, merging anything Standing rule in AGENTS.md ("Never auto-push, PR, or merge"). The maintainer reviews and merges.
Starting or tagging a release A tag push fires CI, builds, signs, publishes a GitHub Release, and updates the appcast that live users poll. Not reversible quietly.
Deleting anything uncommitted (files, stashes, working-tree changes) "Never delete uncommitted changes" — the maintainer's in-progress work may be in the tree.
Editing anything in test-files/ Manual test corpus; todo.md there is owner-edited. Make fixtures elsewhere.
Adding a dependency Current set is deliberately three (swift-markdown, SwiftMath, Sparkle); each new one is a codesign/bundle/update-pipeline liability (see the SwiftMath bundle saga, ARCHITECTURE §8).
Changing .Codex/settings.json hooks or permissions Alters what runs automatically on the maintainer's machine.

When NOT to use this skill

You need… Go to
The invariants' full technical statement and render pipeline edmund-architecture-contract
To debug a failure, read traces/logs edmund-debugging-playbook
The history of a past incident in depth edmund-failure-archaeology
TextKit 2 / AppKit API behavior details textkit2-appkit-reference
Launch flags, debug bundle, settings edmund-config-and-flags
Build issues, stale binaries, environment edmund-build-and-env
Executing a release / operating the app edmund-release-and-operate
The screencapture / ReproScript mechanics edmund-live-repro-and-diagnostics
Test-writing patterns and QA strategy edmund-validation-and-qa
Writing docs / chronicles edmund-docs-and-writing
Caret/selection bug-class specifics edmund-caret-integrity-campaign

Provenance and maintenance

  • Sources: AGENTS.md (repo root), docs/ARCHITECTURE.md §1 §2 §8 §9 §12, .Codex/settings.json (Stop hook), misc/before-you-release.md, misc/how-to-release.md, docs/investigations/delete-drift-investigation.md (rounds 1–6), docs/investigations/archives/callout-title-wrap-investigation.md, git log / git branch -a as of fe8a1f5 (2026-07-05).
  • Commits cited were verified in git log: 5bb2b40 (diff-based undo restore), 9f99795 (round-4 heal), ae61644 (stroked-path callout icon), 1b1420a (round-6 caret re-assert).
  • Two rules rest on maintainer statements rather than repo docs: the measure-from-pixels rule and the test-files/ ownership rule. If either gets written into ARCHITECTURE.md, point at it here.
  • Maintain: when a new incident produces a new rule, add a row to §3 with the incident pointer in the same PR that adds the rule to ARCHITECTURE.md. When the Stop hook in .Codex/settings.json changes, update §1's note.
Edmund编辑器配置与参数目录,涵盖UserDefaults设置、启动参数及编译选项。用于添加/修改设置、排查行为控制标志、调试启动或审计默认值。强调代码为真理,需通过grep验证最新状态。
添加或修改用户设置 查找控制特定行为的启动标志 使用调试标志启动应用 审计默认配置 将新偏好集成到实时编辑器
.agents/skills/edmund-config-and-flags/SKILL.md
npx skills add I7T5/Edmund --skill edmund-config-and-flags -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-config-and-flags",
    "description": "Catalog of every configuration axis in the Edmund Markdown editor — user settings (UserDefaults keys, defaults, where consumed), launch arguments (diagnostic + repro flags), compile-time gates, and logging config. Load when adding or changing a setting, hunting which flag controls a behavior, launching the app with debug flags, auditing defaults, or wiring a new preference into the live editor. This skill drifts fastest of the set — every table ends with a re-verification grep. Not for the invariants (see edmund-architecture-contract), release flags (edmund-release-and-operate), or how to READ the logs (edmund-live-repro-and-diagnostics)."
}

Edmund configuration & flags

Ground-truth catalog of every knob. Code wins over docs — every value below was read from source on 2026-07-05; re-verify with the greps at the end before trusting a value in a decision.

Two source-of-truth files:

  • Sources/edmd/Settings/AppSettings.swift — every UserDefaults key + typed accessor.
  • Sources/edmd/Settings/*View.swift (Appearance / General / Advanced) + FontSettings — the SwiftUI panes (@AppStorage).

Definitions used below: UserDefaults = macOS per-app persisted key/value store; argument domain = passing -<key> <value> on the command line overrides that default for one launch; @AppStorage = SwiftUI wrapper binding a view to a UserDefaults key.


1. User settings (UserDefaults keys)

Every key is a static let in AppSettings.swift. The key string (not the Swift name) is what you pass as a launch arg.

Swift name Key string Purpose
reopenWindows settings.general.reopenWindows Reopen last windows on launch
startupAction settings.general.startupAction What to do at startup (new doc / reopen / nothing)
autoSaveWithVersions settings.general.autoSaveWithVersions NSDocument autosave-in-place vs versions
conflictResolution settings.general.conflictResolution File-changed-on-disk handling
suppressInconsistentLineEndingWarning settings.general.suppressInconsistentLineEndingWarning Silence mixed-line-ending warning
diagnosticLogging settings.general.diagnosticLogging On/off for file logging (opt-out; see §4)
logRetention settings.general.logRetention Days of logs to keep (pruned on configure)
appearanceMode settings.appearance.mode Light / dark / system
maxContentWidthCm settings.appearance.maxContentWidthCm Max column width, stored in CENTIMETRES (see note)
contentWidthUnit settings.appearance.contentWidthUnit Display unit only (cm/in); the stored value is always cm
renderBlankLinesAsBreaks settings.reading.renderBlankLinesAsBreaks Read-mode blank-line handling
sourceMode settings.view.sourceMode When on, Source replaces Edit in the ⌘E toggle; honored on open
verboseEditorDiagnostics settings.advanced.verboseEditorDiagnostics Verbose editor trace (see §4; pairs with diagnosticLogging)
sendCrashLogs settings.advanced.sendCrashLogs Opt-in crash upload — currently INERT (see note)
sentCrashReports settings.advanced.sentCrashReports Dedup set of already-uploaded .ips filenames
lastWindowHeight settings.window.lastHeight Persisted window sizing (see the frame-not-content trap)
automaticallyChecksForUpdates SUAutomaticallyChecksForUpdates Sparkle's own key (not namespaced)

Content width (the physical-column design): persisted as centimetres (maxContentWidthCm); contentWidthUnit is a display unit only. The column is an absolute physical cap converted to points via the display's real PPI (NSScreen.physicalPPI, from CGDisplayScreenSize), applied as a symmetric textContainerInset.width. Default is locale-aware — 5 in (US) / 12 cm (elsewhere) — and doubles as the slider's magnetic snap point. Recomputed on resize and on NSWindow.didChangeScreenNotification. Consumer path lives in EditorTextView+ContentWidth.swift.

Window sizing trap: persistence must round-trip the frame size, not the contentView.bounds size — reapplying content size grows the window by the title-bar + toolbar height on every reopen, and heights below minSize get silently rejected. Save window.frame.size, reapply with window.setFrame(_:) after the toolbar is installed. (Note: the key on disk is settings.window.lastHeight — code, not the lastWindowSize some docs say.)

Crash uploading is inert: sendCrashLogs defaults off AND the Settings ▸ Advanced toggle is commented out in AdvancedSettingsView.swift, and CrashReporter.reportingEndpoint is a REPLACE-ME.invalid placeholder (CrashReporter.swift:27, // TODO: real server). Nothing uploads today. Un-inert it only when a receiving server exists (see edmund-release-and-operate).


2. Launch arguments

macOS reads -<UserDefaults-key> <value> into the argument domain. Pass the key string from §1, not the Swift name. The file to open must be argv[1] (before the flags).

build/EdmundDbg.app/Contents/MacOS/edmd FILE.md \
  -settings.general.diagnosticLogging YES \
  -settings.advanced.verboseEditorDiagnostics YES \
  -debug.reproScript SCRIPT.repro \
  -ApplePersistenceIgnoreState YES
Flag Effect
-settings.general.diagnosticLogging YES Turn on file logging for this run
-settings.advanced.verboseEditorDiagnostics YES Emit the verbose editor trace (sel/active/marked/up/…)
-debug.reproScript <path> DEBUG builds only — replay a keystroke script (ReproScript.swift)
-ApplePersistenceIgnoreState YES Apple's flag — stop state restoration reopening mutated docs

-debug.reproScript is the only Edmund-specific debug key; it does not have an AppSettings accessor — it is read directly in ReproScript.swift. It is compiled out of release builds.


3. Compile-time axes

#if DEBUG gates live in: EditorTextView.swift, EditorTextView+EditFlow.swift, Diagnostics/Log.swift, edmd/App/main.swift, edmd/App/ReproScript.swift. What they gate:

  • The TextKit-1 tripwire (EditorTextView.swift:273+): a DEBUG observer on NSTextView.willSwitchToNSLayoutManagerNotification that asserts if the view ever falls back to TextKit 1. Ships only in DEBUG; the fallback itself is silent and permanent (see edmund-architecture-contract).
  • ReproScript — the whole in-process keystroke driver.
  • Log level thresholdLog.swift compiles a lower floor in DEBUG (debug+) than release (info+); see §4.

Named tuning constants (not user-facing, but they behave like knobs):

Constant Value Where Meaning
fullLayoutMaxLength 100_000 EditorTextView.swift:80 Docs ≤ this many UTF-16 units are kept fully laid out (below the TK2 estimate regime). Consumed at +LazyStyling.swift:133.

4. Logging config

Read Sources/EdmundCore/Diagnostics/Log.swift and EditorTextView+Diagnostics.swift.

  • API: Log.{debug,info,error}(_:category:), Log.measure(_:) { … }.
  • File: ~/.edmund/logs/edmund-YYYY-MM-DD.log, written on a private serial queue.
  • Config flow: AppSettings.applyLogging() pushes the toggle + retention into Log.configure at launch and on change; retention pruning happens there.
  • Two independent switches: diagnosticLogging (writes anything at all) and verboseEditorDiagnostics (adds the per-event editor trace). For a live repro you almost always want both on. Verbose lines are gated behind Log.shouldTrace.
  • The log is opt-out (on by default), retention-pruned; the user only toggles it and picks a retention window in Settings ▸ General ▸ Diagnostics.

Trace-field decoding (sel/active/marked/up/undo/blocks/storLen/rawLen, ⚠︎LEN-MISMATCH, traceSelectionOrigin) is covered in edmund-live-repro-and-diagnostics and edmund-debugging-playbook — one home per fact; this skill only says which flags turn the trace on.


5. ReproScript command surface

Sources/edmd/App/ReproScript.swift, DEBUG only. One command per line, # comments allowed.

Command Effect
sleep <ms> wait before next command
caret <needle> place caret before first occurrence of <needle>
type <text> one real key event per char (~80 ms apart)
backspace <n> n real delete keystrokes (~300 ms apart)
bypassdelete <needle> simulate drag-move source deletion: shouldChangeText + storage mutation, no didChangeText
assertcaret <needle> log PASS/FAIL iff caret sits exactly before <needle>
logsel log selection, rawSource length, doc count

Adding a command is ~10 lines in ReproScript.swift. Usage, soak patterns, and launch recipe: edmund-live-repro-and-diagnostics.


6. How to ADD a setting (checklist)

Worked from an existing real path (sourceMode / content width). To add a user-facing setting:

  1. Add a static let key + typed accessor in AppSettings.swift (namespace the key string: settings.<area>.<name>).
  2. Bind it in the relevant SwiftUI pane with @AppStorage(AppSettings.<key>).
  3. If it must affect open documents live, add/extend an applyTo… broadcast (see the font/line-height/content-width applyTo… helpers) so every open Document.editor picks it up — a setting that only takes effect on next open is usually a bug.
  4. Pick a sane default (register it, or make the accessor default when absent).
  5. New behavior needs a test + (if it draws) a screencapture check — route through edmund-change-control and edmund-validation-and-qa.

When NOT to use this skill

  • Understanding why an invariant exists → edmund-architecture-contract.
  • Release/signing/appcast flags, RELEASE_TOKEN, Sparkle keys → edmund-release-and-operate.
  • Interpreting log output / running a repro → edmund-live-repro-and-diagnostics.
  • Which change needs which gate → edmund-change-control.
  • Build-time flags in the toolchain sense (stale builds, swift package clean) → edmund-build-and-env.

Provenance and maintenance

Verified 2026-07-05 against source. This skill drifts fastest — re-verify each table before relying on it:

# §1 all keys + strings:
grep -nE 'static let [a-zA-Z]+ = "[a-zA-Z0-9._]+"' Sources/edmd/Settings/AppSettings.swift
# §1 crash toggle still commented out / endpoint still placeholder:
grep -n 'Crash reports:' Sources/edmd/Settings/AdvancedSettingsView.swift
grep -n 'reportingEndpoint' Sources/EdmundCore/Diagnostics/CrashReporter.swift
# §2 repro flag key:
grep -n 'debug.reproScript' Sources/edmd/App/ReproScript.swift
# §3 tripwire + constant:
grep -n 'willSwitchToNSLayoutManager' Sources/EdmundCore/TextView/EditorTextView.swift
grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
# §5 repro commands:
grep -oiE '"(sleep|caret|type|backspace|bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u

Known doc-vs-code discrepancies (code wins): ARCHITECTURE §7 calls the window-size key lastWindowSize; the code key is settings.window.lastHeight (lastWindowHeight). If you touch window persistence, trust the code.

Edmund应用调试指南,用于快速排查崩溃、渲染错误、光标漂移等异常。提供症状分类表、日志分析步骤、构建检查及已知Bug清单,旨在避免重复造轮子并加速定位根因。
收到关于Edmund应用的bug报告 遇到意外行为或应用崩溃
.agents/skills/edmund-debugging-playbook/SKILL.md
npx skills add I7T5/Edmund --skill edmund-debugging-playbook -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-debugging-playbook",
    "description": "Load this FIRST when any bug report or unexpected behavior arrives in Edmund: caret lands in the wrong place after delete\/typing, viewport jumps or scroll-to-target misses, rendering is wrong or missing, empty bands or clipped lines, everything suddenly renders as plain text, app crashes or won't launch, a code change \"doesn't take\" after rebuild, undo\/redo lands the viewport wrong, IME\/CJK\/accent input misbehaves, right-click shows the wrong menu, window grows on reopen, or a Sparkle update fails. Symptom-to-mechanism triage table, the traps that cost real debugging time, discriminating trace checks, the repro escalation ladder, and the open-bug inventory so known bugs are not rediscovered fresh."
}

Edmund debugging playbook

Date-stamped 2026-07-05. Runbook for triaging any Edmund bug. Start at the triage table, run the discriminating first check before forming a theory, and check the open-bug inventory before declaring a discovery.

When NOT to use

  • Making a change, not chasing a bug → edmund-change-control.
  • You already know the bug is live-only and need to build a deterministic repro → edmund-live-repro-and-diagnostics (full ladder detail; summary in §5 here).
  • Caret-drift class specifically, with its six-round history → edmund-caret-integrity-campaign.
  • Build/toolchain/stale-binary mechanics beyond the quick checks here → edmund-build-and-env.
  • Release, signing, appcast, Sparkle pipeline → edmund-release-and-operate.
  • TextKit 2 / AppKit API semantics reference → textkit2-appkit-reference.
  • Launch flags and settings keys reference → edmund-config-and-flags.
  • How past investigations were run and why → edmund-failure-archaeology, edmund-research-methodology.
  • Pre-merge verification of a fix → edmund-validation-and-qa.

1. First 15 minutes — any new bug

Run this checklist before hypothesizing. Every step is cheap; skipping them is how rounds 1–5 of delete-drift shipped fixes that came back.

  1. Check the open-bug inventory (§6). If the symptom matches a known open bug, you are done triaging — link the backlog entry and its repro asset.
  2. Get the logs. ls -t ~/.edmund/logs/ and read the day's file (edmund-YYYY-MM-DD.log). Grep for the three permanent breadcrumbs:
    grep -n "healing storage edit that bypassed didChangeText\|repairing content above origin\|recovered stranded desync on focus regain" ~/.edmund/logs/edmund-*.log
    
    Also grep for invariant: (the always-on storage==rawSource tripwire) and ⚠︎LEN-MISMATCH.
  3. Find the first bad line and walk BACKWARDS. The user-visible symptom is often the second half of a two-part mechanism — the round-6 caret drift was armed by a silent bypass 80 seconds and dozens of healthy edits before the leap. Never start reading at the symptom timestamp.
  4. Rule out a stale build if this follows a rebuild: grep strings .build/arm64-apple-macosx/debug/edmd for a long string literal unique to the new code (≤15-byte literals are inlined on arm64 and never appear); shasum the binary. See §3c.
  5. Check git history for prior art. git log --oneline -- <suspect file> plus the investigation docs in docs/. The viewport-glitch investigation found four earlier fixes all working around the same unnamed root cause.
  6. Reconstruct the document. Get the user's file or rebuild it from the trace's block counts/lengths. Wrapped-paragraph geometry and block kinds matter; do not repro against "hello world".
  7. Before touching any running app: pgrep -lx edmd then ps -o lstart=,command= -p <pid>. The user's daily-driver app shares the binary name. Never blanket pkill -x edmd — kill only the PID you launched.
  8. Row found in §2 → run its discriminating check. No row → escalate per §5, and add the new row here when it's understood.

2. The triage table

Symptom Likely mechanism Discriminating first check Where next
Caret lands blocks away after delete or typing; text itself correct Delete-drift class: a storage edit bypassed didChangeText (drag-move to no valid target), or TextKit 2's queued _fixSelectionAfterChangeInCharacterRange fired at a later endEditing grep "healing storage edit that bypassed didChangeText" ~/.edmund/logs/edmund-*.log and read the trace around it; look for selectionDidChange with up=Y at a surprising position edmund-caret-integrity-campaign; docs/investigations/delete-drift-investigation.md
Every delete drifts, persistently, until an app switch fixes it Stranded IME composition: hasMarkedText() stuck true, didChangeText bails forever, model frozen Grep logs for recovered stranded desync on focus regain; check storage.string == rawSource docs/investigations/delete-drift-investigation.md rounds 1–2
Scroller jumps; scroll-to-target misses; content shifts on scroll TextKit 2 height estimates — off-screen frames are guesses corrected as layout reaches them Doc length vs fullLayoutMaxLength (100k UTF-16, EditorTextView.swift) — ≤100k should be fully laid out by the settle; >100k is estimate territory docs/investigations/viewport-glitch-investigation.md
First line unreachable above the top; scroller already at 0 TK2 strands fragments at negative y after a top-of-document edit grep "repairing content above origin" ~/.edmund/logs/edmund-*.log — present means the repair fired (diagnosis confirmed, repair maybe raced); absent means a different cause docs/investigations/viewport-glitch-investigation.md Bug 2 (repair unconfirmed live)
Undo/redo lands viewport in the wrong place; changed text not selected Regression of the diff-based restore contract (5bb2b40): a full recompose resets every fragment to an estimate, then the scroll measures the estimates Confirm restoreSnapshot still routes through range-bounded recomposeReplacing, never full recompose (+Undo.swift); check the changed range, not the stored caret, drives the viewport docs/investigations/viewport-glitch-investigation.md Bug 1
Code/visual change "doesn't take" after rebuild STALE BUILD — SwiftPM printed Build complete! without relinking edmd strings .build/arm64-apple-macosx/debug/edmd | grep "<long new literal>"; shasum before/after edmund-build-and-env; §3c
App crashes the instant any LaTeX renders SwiftMath *.bundle missing from the .app root (its Bundle.module is hardcoded to Bundle.main.bundleURL) ls build/Edmund.app/*.bundle scripts/build-app.sh copy step; ARCHITECTURE §8
Everything suddenly renders as plain text; all styling gone Silent, permanent TextKit 1 reversion: an NSLayoutManager API was touched or an NSTextBlock/NSTextTable attribute entered storage DEBUG builds assert via the tripwire (textKit1FallbackTripwire, willSwitchToNSLayoutManagerNotification); audit recent diffs for layoutManager / NSTextBlock ARCHITECTURE §2; textkit2-appkit-reference
Empty bands or clipped lines after a restyle Attribute-only change without invalidateLayout(for:) — TK2 keeps the stale fragment frame Is the misbehaving path a new styling path? recomposeDirty and the idle drain already invalidate; new paths must too ARCHITECTURE §8
Weird behavior only while composing CJK / accents / emoji A storage-touching styling path missing the !hasMarkedText() guard (including async work scheduled before composition began) Audit the new/changed path for the guard; check logs for a persisting LEN-MISMATCH during composition ARCHITECTURE §8; docs/investigations/delete-drift-investigation.md
Callout at end of file shows an extra colored line not prefixed by > KNOWN OPEN BUG — lives in the LIVE incremental restyle path only, not static rendering (a fresh full render is clean) Confirm against misc/bug-repros/callout-extra-line-rendered-at-bottom.mov misc/backlog.md
Image leaves a large blank space below it KNOWN OPEN BUG misc/bug-repros/image-blank-after.mov misc/backlog.md
Footnotes don't render (edit or read mode) KNOWN OPEN BUG misc/backlog.md
Right-click on the toolbar view-mode button shows "Customize Toolbar…" NSToolbar with allowsUserCustomization claims every secondary click over the toolbar, beating view-level handlers Verify the DocumentWindow.sendEvent(_:) intercept is intact (it swallows the click inside the button's bounds); note it does not cover true fullscreen ARCHITECTURE §8
Window grows by title-bar height on every reopen Frame-vs-content-size persistence trap: saving contentView.bounds.size and re-applying as contentRect Confirm lastWindowSize round-trips window.frame.size via setFrame after the toolbar is installed (Document.swift) ARCHITECTURE §8
Sparkle update fails: "The update is improperly signed and could not be validated" Bundle not sealed: signing only the main binary leaves no _CodeSignature/CodeResources; or the EdDSA keypair diverged from SUPublicEDKey Does build-app.sh still seal the whole .app before copying the SwiftMath bundle in? edmund-release-and-operate; ARCHITECTURE §8/§13
Callout/overlay icon wedges a wrapping line down to one line TK2 image-on-multiline-fragment wedge: drawing an image on a wrapping fragment collapses its layout; shapes do not Is the overlay an NSImage on a fragment that can wrap? Convert to a stroked CGPath (the custom-title callout icon fix) docs/investigations/archives/callout-title-wrap-investigation.md
Dragging produces no visible selection at all Not a bug: a selection (possibly whole-document) was already active, and dragging inside an existing selection is AppKit's drag-move gesture Trace: was there a selectionDidChange with a large sel before the drag began? docs/investigations/viewport-glitch-investigation.md Bug 3 phase 1
Viewport oscillates up/down during a steady drag-select Two scroll policies fighting: drag autoscroll vs a reveal that follows the wrong end of a taller-than-viewport selection Confirm the scrollRangeToVisible override still reveals the selection's nearest end (+TypewriterScroll.swift, commit 340fcbc) docs/investigations/viewport-glitch-investigation.md Bug 3 phase 2
Edits do nothing at all (not drift — dropped) isUpdating stuck true would make shouldChangeText return false Trace shows shouldChangeText never returning OK; distinct from the drift signature +EditFlow.swift
Launching via open shows old behavior LaunchServices foregrounded a running instance, or ran a stale cached/translocated copy pgrep -lx edmd first; launch by direct exec of the bundle binary §3e; edmund-build-and-env

3. The traps that cost real time

Each of these burned hours to days. Read before shipping any fix.

(a) Shipping caret fixes on reasoning alone. Delete-drift rounds 1–5 each shipped a plausible, well-argued fix — and each came back. Only round 6, the first with a frozen deterministic live repro (bypassdelete script), named the actual mechanism (_fixSelectionAfterChangeInCharacterRange queued by a bypassed edit, firing at the next endEditing) — and its first fix attempt failed in the repro within a minute, which reasoning would never have caught. Lesson: time spent making the failure cheap to observe beats time spent reasoning about the fix. Freeze the repro before writing the fix.

(b) Undo/redo viewport drift. Two defects hid behind one symptom: restoreSnapshot ran a full recompose (discarding all TK2 layout, so the follow-up scroll measured freshly manufactured estimates) and performUndo recorded the redo snapshot with the caret at undo-invocation time, not at the edit. Lesson: a wrong-scroll symptom can be a geometry bug and a plain stale-state bug stacked; fix and verify them separately. The contract since 5bb2b40: diff the snapshot, apply via recomposeReplacing, select the changed text, let the changed range drive the viewport.

(c) Stale binaries produce false conclusions. In round 6, swift build twice printed Build complete! while linking a stale edmd — the compile ran, the relink silently didn't — and two "failed" fix iterations were phantoms. Detect: grep strings on the binary for a long literal unique to the new code. Cure: swift package clean (or rm -rf .build for release weirdness). Never hand-delete .build/…/edmd.build/ — that corrupts the output-file-map and wedges the target until a full clean.

(d) Headless-green ≠ fixed for input-layer bugs. The round-6 unit test reconstructs the exact document and gesture and passes with and without the fix: the test harness runs AppKit's deferred selection fixup synchronously, so the broken state never forms. A green test proves nothing about the live NSTextView / TextKit 2 / input-context class. The scripted live repro is the regression harness; the unit test is only a contract spec.

(e) open -a runs stale cached copies. LaunchServices can foreground an already-running instance instead of relaunching, and can execute a stale cached/translocated copy of the bundle — you debug last hour's code. Always launch by direct exec: build/Edmund.app/Contents/MacOS/edmd file.md & (after the §1 step-7 pgrep check).

(f) Trusting off-screen fragment y-coordinates. A TK2 fragment's frame is real only once laid out; everything off-screen, plus total document height, is an estimate. Any code that measures before ensuring layout of the viewport↔target span lands wrong (this is Bug 1a, the typewriter-scroll gotcha, and most historical viewport glitches). Ensure layout for the target range first, then align to real geometry — and never verify a visual fix from headless layout: measure from screencapture pixels.

4. Discriminating experiments — cheap checks that split hypothesis spaces

Verbose diagnostics launch (defaults keys are namespaced; the file must be argv[1]):

build/Edmund.app/Contents/MacOS/edmd FILE.md \
  -settings.general.diagnosticLogging YES \
  -settings.advanced.verboseEditorDiagnostics YES

Or toggle in Settings ▸ Advanced ("Save diagnostic logs" + "Verbose editor tracing"). Logs land in ~/.edmund/logs/edmund-YYYY-MM-DD.log.

Trace field vocabulary (source of truth: Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift, diagnosticState). Every trace line ends with:

Field Meaning
sel={loc,len} current selection
active= active block index (or nil)
marked= marked-text range, - if none (non-- outside a live composition = stranded)
up=Y/N isUpdatingY means the event arrived MID-RECOMPOSE
undo=Y/N isUndoRedoing
blocks= block count
storLen= / rawLen= storage vs rawSource lengths
⚠︎LEN-MISMATCH appended when they differ

Healthy vs suspect edit orderings:

  • Healthy: shouldChangeTextselectionDidChange (up=N) → synced. A transient ⚠︎LEN-MISMATCH between those lines is normal (storage moves before rawSource syncs).
  • Suspect: a selectionDidChange with up=Y at a surprising position; a persisting LEN-MISMATCH; a shouldChangeText with no synced/SKIPPED/DEFERRED line after it (bypassed didChangeText); the healing storage edit that bypassed didChangeText breadcrumb.

traceSelectionOrigin: under verbose tracing, any selection change arriving mid-recompose (up=Y) or with an unconsumed pendingEdit logs a condensed call stack naming the AppKit path that moved the caret. This is what named _fixSelectionAfterChangeInCharacterRange in round 6. If your bug moves the caret and you don't know who moved it, this answers it in one run.

Walk backwards from the first bad line, not forwards from the symptom. The round-6 drift was armed 80 seconds before the visible leap. Find the first line whose state is wrong, then read earlier.

verifyEditorInvariants (same file): the O(1) length check (storage.length != rawSource.length) logs an error whenever logging is on — no verbose toggle needed — so a hard-invariant break always leaves an invariant: line. The full structural checks (string equality, blocks reconstruct rawSource, block ranges in bounds) run under verbose tracing and assert in DEBUG. An invariant: error in a user's log is a model desync, full stop; triage as the delete-drift class.

If the existing logging didn't capture the deciding fact, add the log line first and reproduce again — one breadcrumb beats ten speculative fixes. Keep good ones behind Log.shouldTrace and ship them.

5. The escalation ladder (summary)

Full detail, ReproScript command reference, CGEvent driver, and soak-script method: edmund-live-repro-and-diagnostics and docs/dev-guides/live-repro-guide.md. Work down; stop at the first level that reproduces.

  1. Plain unit test (makeEditor()) — model/parsing/styling logic.
  2. Windowed unit test (NSWindow + NSScrollView, real deleteBackward) — adds layout, viewport, first-responder. Failure to repro here is evidence, not defeat: it points at deferred/queued AppKit state and at levels 3–4.
  3. In-process ReproScript — DEBUG builds accept -debug.reproScript <path> (Sources/edmd/App/ReproScript.swift); replays a keystroke script through the real window.sendEvent path. No Accessibility/TCC needed, works with the window on an invisible Space. Commands: sleep, caret, type, backspace, bypassdelete, assertcaret, logsel. Launch with -ApplePersistenceIgnoreState YES and recreate the test document fresh each run. The default for live bugs.
  4. CGEvent driver — only for paths that must originate as HID events (drag-select, drag-move, autoscroll). TCC decides per session; if input doesn't land after one test click, fall back to level 3 immediately.
  5. Instrumented field occurrence — can't trigger it yourself: add the decisive breadcrumb, ask the user to enable verbose tracing, and wait. Days of latency; make sure the next occurrence is decisive.

After a fix: freeze the repro script, run a soak (several trigger cycles in one app run with assertcaret checks), then full swift test.

6. Open-bug inventory

Known open bugs — check here before "discovering" one. Sources: misc/backlog.md (authoritative list) and docs/ROADMAP.md (larger themes, e.g. "TextKit 2 viewport stabilization" is a v1.0.0 item — viewport estimate glitches are a known, partially-mitigated class). All entries below are OPEN as of 2026-07-05.

Bug Status Repro asset
Delete caret drift (class) Open as a class; rounds 1–6 fixed, watching for round 7 misc/bug-repros/delete-caret-drift-{1.mp4,2.mov,3.mov,4.mov} + matching logs
Inaccurate viewport estimates & related Open class; small-doc mitigations shipped, Bug-2 repair unconfirmed live
Callout as last element renders an extra colored line Open; live incremental restyle path only, NOT static rendering misc/bug-repros/callout-extra-line-rendered-at-bottom.mov
Image creates large empty space below Open misc/bug-repros/image-blank-after.mov
Footnotes don't render (edit or read mode) Open
Math environments don't render in read mode Open misc/bug-repros/math-baseline-read-mode-png.png (related baseline issue)
Math environments have wrong padding in edit mode Open
Max content width not applied to read mode Open
Images should shrink when content size is small Open
Tables should wrap when content size is small Open
Table cell content wraps out of the cell Open
Click-to-select / select+delete sometimes doesn't work Open, intermittent
Scroll glitch from height changes outside viewport Open, unreproduced ("Lurking" in backlog)
Cursor stuck at indented position after indented editing Open, unreproduced; awaiting screen recording
Undo/Redo and Copy/Paste scrolling "failing again" Open, unreproduced (post-fix recurrence report)

Do not relabel any of these as fixed without a verified repro flip; no oversell. When you fix one, update misc/backlog.md and this table in the same branch.

7. House rules while debugging

  • Never blanket pkill -x edmd. The user's daily-driver app shares the binary name. pgrep -lx edmd + ps -o lstart=,command= -p <pid>, then kill only your own PID (pkill -f EdmundDbg if you launched the debug bundle).
  • Do not request Computer Access — Screen Recording and Accessibility are already granted.
  • Measure visuals from screencapture pixels (capture by window id, see ARCHITECTURE §8), never from headless layout alone.
  • Never mutate storage while hasMarkedText() — including in any diagnostic or repro code you add.
  • Never auto-push, PR, or merge. Branch off main per fix; commit small and often.
  • Logs in ~/.edmund/logs are app-owned and fair game to read and quote.

Provenance and maintenance

Built 2026-07-05 from: docs/ARCHITECTURE.md (§2, §8, §9), docs/investigations/delete-drift-investigation.md (rounds 1–6), docs/investigations/viewport-glitch-investigation.md, docs/investigations/archives/callout-title-wrap-investigation.md, docs/dev-guides/live-repro-guide.md, misc/backlog.md, docs/ROADMAP.md, and Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift. Log strings (healing storage edit that bypassed didChangeText, repairing content above origin, recovered stranded desync on focus regain), launch-flag keys, fullLayoutMaxLength, ReproScript commands, the TK1 tripwire, and commit 5bb2b40 were verified against the source tree on that date.

Maintain it like the codebase docs: when a new bug class is understood, add its triage row; when a trap costs real time, add its story to §3; when a backlog bug opens or closes, sync §6 with misc/backlog.md in the same branch. If a row's discriminating check stops matching the code (renamed log string, moved file), fix the row — a stale runbook is worse than none. Deeper mechanism detail belongs in the sibling skills and docs/ investigation files, not here; this file stays a triage surface.

用于规范Edmund项目文档编写与事实路由。明确各文档职责,提供写作风格、变更日志格式及调查模板指南。适用于撰写、更新任何项目文档或决定新知识点归属场景,不处理代码修改或发布操作。
撰写或更新项目文档(如ARCHITECTURE.md, README.md等) 决定新发现的事实、Bug或功能建议应存放于何处
.agents/skills/edmund-docs-and-writing/SKILL.md
npx skills add I7T5/Edmund --skill edmund-docs-and-writing -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-docs-and-writing",
    "description": "Documentation of record for the Edmund repo: which doc owns which fact, and how to write in the house style. Load whenever you are writing or updating ANY project doc — docs\/ARCHITECTURE.md, CHANGELOG.md, README.md, docs\/ROADMAP.md, misc\/backlog.md, a docs\/<topic>-investigation.md write-up, release docs — or deciding WHERE a newly learned fact, gotcha, bug, or feature idea belongs. Covers the docs-of-record map, the fact-routing decision table, the investigation-doc template, CHANGELOG format (machine-extracted for release notes), commit-message conventions, and doc maintenance duties. Not for making the code change itself, release mechanics, or debugging — see \"When NOT to use this skill\"."
}

Edmund docs and writing

Date-stamped 2026-07-09. Every claim below was verified against the files on main at that date; re-verify paths before trusting this after major reorganizations.

When NOT to use this skill

You are actually doing Use instead
Changing code / designing a mechanism edmund-architecture-contract
Branch/commit/PR mechanics, pre-commit checklist edmund-change-control
Cutting a release, appcast, Sparkle, CI edmund-release-and-operate
Diagnosing a bug (not writing it up) edmund-debugging-playbook, edmund-live-repro-and-diagnostics
Mining past investigations for technique edmund-failure-archaeology
Marketing copy, positioning, alternatives research edmund-external-positioning
Build flags, env, debug bundle edmund-build-and-env, edmund-config-and-flags

This skill is for prose: what to write, where it lives, how it should read.

1. The docs-of-record map — one home per fact

Every fact has exactly one home; everywhere else gets a pointer. All paths exist and are current as of 2026-07-09.

Doc Owns Notes
docs/ARCHITECTURE.md HOW the system works: build/test commands (§1), the two invariants (§2), render pipeline (§3), edit/undo flow (§4), TextKit 2 drawing (§5), feature map (§6), settings (§7), gotchas (§8), known issues (§9), code debt (§10), agent quick start (§11), working agreements (§12), release/CI (§13), references (§14) THE agent-onboarding doc. Its own header states the rule: when you learn something non-obvious or change an invariant, edit this file in the same PR.
docs/architecture/README.md Human developer overview: what Edmund is, the two invariants (summarized, not owned), a map of docs/architecture/'s deep docs and the sibling investigations//dev-guides/ folders, common quirks (each a pointer, never a new claim), getting-started commands The human entry point ARCHITECTURE.md's header note links to. Every fact here traces to ARCHITECTURE.md or a deep doc — this file summarizes, never owns.
docs/architecture/<topic>.md Deep narrative write-up of one subsystem (e.g. editor-pipeline.md, text-system.md) The "deep-doc" pattern: a fact's statement lives in ARCHITECTURE.md, its explanation lives here, each links to the other.
docs/architecture/extensibility.md The design-of-record for themes/extensions: vision, current state (verified against main and the unmerged feat/extensions-registry-and-tab branch), themes/extensions design, staged implementation plan, honest risks Design only, not yet implemented on main. ARCHITECTURE.md gets no extensibility section until code lands (same-PR rule) — this doc is the exception to the deep-doc pattern above: there is no ARCHITECTURE.md statement to expand yet.
docs/architecture/sandboxing.md The App Sandbox preparation plan: CotEditor reference model, touchpoint-to-fix inventory, entitlements/build-variant mechanics, the ~/.edmund/ onboarding grant, staged plan (SB0-SB4), open decisions Plan only, nothing sandboxed on main. Same design-doc exception as extensibility.md: no ARCHITECTURE.md statement exists yet; when a stage lands, its facts move to ARCHITECTURE.md in the same PR.
README.md WHAT/WHY for users: differentiators, screenshots, install (incl. the Gatekeeper "DAMAGED" xattr -dr com.apple.quarantine workaround), dependencies, alternatives, acknowledgements, license User-facing; no internals.
CHANGELOG.md User-facing version history, Keep-a-Changelog style ## [x.y.z] sections are machine-extracted for release notes — exact format matters (§4 below).
docs/ROADMAP.md Versioned feature plan: ## v1.0.0, ## v1.x, # v.2.0.0 sections of checkbox lists, grouped by theme (editing, extensions, macOS integrations) Has a Last updated: YYYY-MM-DD line under the title — refresh it when you edit.
misc/backlog.md The maintainer's working priority list: ## Now (small releases) (Marketing / On-going bugs / Bugs / UI/UX / Features), ## Next, ## Later, roadmap mirrors, ### Lurking (Unreproduceable), ## Done Stated priority: Marketing = Bugs >= UI/UX > Features. Bug entries carry repro pointers (misc/bug-repros/*.mov, .log, or ~/Desktop paths).
docs/investigations/<topic>-investigation.md Deep multi-round investigation chronicles for active bug classes Existing: delete-drift-, viewport-glitch-investigation.md. Template in §5.
docs/investigations/archives/<topic>-investigation.md Chronicles for closed/resolved bug classes Existing: callout-bottom-line-, callout-title-wrap-investigation.md.
docs/dev-guides/live-repro-guide.md Method doc: the escalation ladder for reproducing live-app bugs Referenced from ARCHITECTURE §11.
misc/before-you-release.md Pre-flight readiness checklist Pairs with how-to-release.md; cross-ref edmund-release-and-operate.
misc/how-to-release.md Release mechanics (CI tag path, local release.sh) Same.
AGENTS.md (root) Behavior contract for agents: env, git practices, pre-commit checklist, the comment-at-the-code rule Short by design; it delegates the "how" to ARCHITECTURE.
LICENSES/ Vendored license texts (currently lucide.txt for the Lucide icon SVGs) Add one when vendoring third-party assets.
Info.plist CFBundleShortVersionString + CFBundleVersion — the version of record Must match the CHANGELOG section header at release (see misc/before-you-release.md §3).

Note: misc/backlog.md and docs/ROADMAP.md currently duplicate the v1.0.0/v1.x/v2.0.0 sections (backlog carries an extended copy). ROADMAP is the public plan; backlog is the working list. When they disagree, treat ROADMAP as the versioned commitment and backlog as scratch — and mention the drift to the maintainer rather than silently reconciling.

2. Where does a new fact go — decision table

Route the fact FIRST, then write. One home; cross-reference from elsewhere.

You learned / produced Home How
Code quirk, edge case, workaround, non-obvious why Comment at the code site House rule (root AGENTS.md): "Document non-obvious behavior... as a short comment at the code itself — not in commits or this file."
Architectural gotcha that will bite the next agent ARCHITECTURE.md §8 Bold lead-in bullet + one-line repro/symptom + pointer to any deeper write-up. Same PR as the code change.
New known issue / structural constraint ARCHITECTURE.md §9 It has an explicit placeholder: "Add new ones here as you find them — with a one-line repro and a pointer to any deeper write-up in docs/."
Code debt / incomplete implementation ARCHITECTURE.md §10 Its footer says: track code-debt here, roadmap items in README/ROADMAP.
Changed invariant, new subsystem, new pipeline step ARCHITECTURE.md §2–§7 (the relevant section) Update in the same PR — header rule.
Multi-round investigation (2+ hypothesis cycles, live repro work) New docs/<topic>-investigation.md Use the §5 template. ALSO add a one-bullet §8 gotcha summarizing the rule it produced, pointing at the doc.
User-visible change (fix/feature/rename) CHANGELOG.md under the next ## [x.y.z] Format in §4. Link the issue and any investigation doc.
New bug found (reproducible) misc/backlog.md under Bugs - [ ] Bug: <symptom>. See <repro pointer>. Drop repro assets (video/log) into misc/bug-repros/.
New bug found (unreproducible so far) misc/backlog.md### Lurking (Unreproduceable) One line + "wait for screen record" style note.
Bug that is really code debt (design limitation) ARCHITECTURE.md §9 e.g. the image-on-wrapping-fragment constraint.
Feature idea, near-term (next few small releases) misc/backlog.md (Now/Next/Later) Sorted by priority + difficulty within category.
Feature idea, versioned/strategic docs/ROADMAP.md under the right version Refresh Last updated.
Repro method / debugging technique docs/dev-guides/live-repro-guide.md Method docs, not per-bug chronicles.
Release procedure change misc/how-to-release.md / misc/before-you-release.md + ARCHITECTURE.md §13 §13 owns the mechanism + failure modes; misc/ owns the operator checklist.
Agent workflow improvement ARCHITECTURE.md §12 Its footer invites this: "If you (the agent) improve this workflow... update this section."
Vendored third-party asset LICENSES/<name>.txt + a feature-map note in §6 Follow the Lucide precedent.
Deep explanation of an existing subsystem docs/architecture/<topic>.md A fact's statement lives in ARCHITECTURE.md; its explanation lives in the deep doc; each links to the other.

The same-PR rule is the load-bearing one. Doc updates that ride the code PR actually happen (see cf10741, b600e12, c4a602b in history); doc updates deferred to "later" don't.

3. House style

Derived from reading ARCHITECTURE.md and the investigation docs. Match it.

  • Dense, specific, evidence-first. State the mechanism and the proof, not vibes. "Verified against that exact API" (§8 Sparkle bullet), timestamps and selection ranges quoted verbatim in investigation docs.
  • Bold lead-ins for gotcha bullets, then the explanation: - **Stale release builds**: .... Scannable list, detail inline.
  • Backticks for every file, symbol, flag, and command: `recomposeDirty`, `+EditFlow` (the extension-file shorthand), `-debug.reproScript`.
  • One-line repro pointers, not embedded essays: "See misc/bug-repros/image-blank-after.mov", "grep ~/.edmund/logs for repairing content above origin".
  • Honest status labels. The docs say "unconfirmed live", "theory + targeted repair, not a confirmed kill", "Verification limits (honest gaps)", "the test documents intent; the leap only reproduces under live layout". Never claim verification you didn't do. No oversell.
  • Section anchors as cross-refs: "see §8", "ARCHITECTURE §13" — used across ARCHITECTURE, AGENTS.md, before-you-release.md. If you renumber sections, grep the repo for § and fix every reference.
  • Address "you", the next agent/engineer: "will bite you", "Context for anyone who sees the bug again", "Next time it happens: ...".
  • Record what failed, not just what worked — investigation docs keep the overturned theories and the phantom fixes (stale-binary trap) because the dead ends are the reusable knowledge.

Commit messages (from git log --oneline -50)

Mixed but patterned: conventional prefixes dominate for fixes and docs — fix(scope): ... (scopes seen: editor, layout, scroll, undo, release-workflow, changelog-to-html), docs: ..., occasional refactor:, appcast: add Edmund X.Y.Z, release X.Y.Z. Chores and README work often use plain imperative subjects ("Update README", "Add assets for README"). Branches: fix/<slug>, docs/<slug>, chore/<slug>. When in doubt: fix(scope): for behavior changes, docs: for doc-only commits, plain imperative for chores. Never auto-push, PR, or merge — only when asked.

4. CHANGELOG format — machine-read, get it exact

.github/workflows/release.yml extracts release notes with:

awk "BEGIN{p=0} /^## \[${VERSION}\]/{p=1;next} p && /^## \[/{exit} p{print}"

So the section header MUST be ## [x.y.z] at line start, version matching CFBundleShortVersionString exactly; the section ends at the next ## [. scripts/changelog-to-html.py converts the same section to HTML for Sparkle's update dialog (it folds wrapped bullet lines into their <li> — wrapping bullets is safe). Full pipeline: edmund-release-and-operate.

House format (verify against the file; current entries follow this):

## [0.1.4] — 2026-07-XX

### Fixed
- <User-facing symptom, past tense optional> ([docs](docs/<topic>-investigation.md)) [#NNN](https://github.com/I7T5/Edmund/issues/NNN)

---
  • Em dash between version and ISO date; --- separator between versions.
  • Subsections used so far: ### Added, ### Changed, ### Fixed (Keep a Changelog 1.1.0 vocabulary).
  • Entries describe the user-visible effect, not the mechanism; mechanism lives in the linked investigation doc / ARCHITECTURE.
  • An optional free-text line under the header is fine (0.1.2 has one) — the awk extraction includes it.

5. The investigation-doc template

Derived from docs/investigations/delete-drift-investigation.md (6 rounds) and docs/investigations/viewport-glitch-investigation.md. Both open with why the doc exists ("Context for anyone who sees the bug again... records the trail end to end") and name the fixing commits/branch up front. Chronicle structure: each recurrence is a new ## Round N appended to the same doc — symptom → diagnosis → root cause → fix → verification, with limits stated.

Skeleton (copy-paste):

# <Area> "<bug nickname>" — investigation notes

Context for anyone who sees this again. <One line on why it was hard:
intermittent / state-dependent / looked nothing like its cause.>

Fixed on branch `fix/<slug>`, commits: `<sha>` — <subject>, ...

## Symptom

<Exact user-visible behavior. Bulleted key properties, each a discriminating
fact ("caret-only, text fine"; "never right after launch"). Evidence
pointers: `misc/bug-repros/<file>`, `~/.edmund/logs/...`.>

## How it was diagnosed

1. <Numbered steps in the order they happened, including overturned
   theories and WHY each clue narrowed the space.>

## Root cause

<The mechanism, in bold where it matters. Explain why every symptom
property follows from it.>

## The fix

<What changed, in which file, and why that shape (defenses tried and
rejected count too).>

## Verification

<Tests added, live repro results, suite count. Then an honest limits
subsection: what was NOT reproduced/confirmed, and the breadcrumb to grep
for if it recurs.>

## If it ever recurs

<Ordered checks for the next investigator: which invariant/log/flag to
inspect first.>

## Round 2: <one-line summary>   ← append on recurrence, same structure

After writing one: add the one-bullet gotcha to ARCHITECTURE §8 with a pointer, add the CHANGELOG entry with a ([docs](docs/...)) link, and check the corresponding misc/backlog.md box (or move it under On-going bugs).

6. Maintenance duties

Do these whenever you touch the relevant doc; they rot otherwise.

  • ARCHITECTURE placeholders: §9 and §10 end with italic "Add new ones here" / "track code-debt here" lines — keep them last in their lists so the invitation stays visible.
  • ROADMAP Last updated: — bump the date on any edit.
  • Backlog hygiene: check - [x] boxes when a fix ships (move to ## Done only if following the existing pattern — completed items live there); keep repro pointers valid; don't reorder the maintainer's priority sorting.
  • README's inline HTML comments are the maintainer's own edit notes (e.g. <!-- Replace "minimal" with ... -->) — leave them unless acting on them.
  • At release: CHANGELOG section header ↔ Info.plist version ↔ appcast <item> must agree; the checklist is misc/before-you-release.md, the mechanics edmund-release-and-operate.
  • Section renumbering in ARCHITECTURE: grep the whole repo (docs, misc, AGENTS.md, skills) for § references before and after.
  • Never edit test-files/todo.md — the maintainer owns it.

Provenance and maintenance

Written 2026-07-05 against main at fe8a1f5 (release 0.1.3). Sources, all read directly: docs/ARCHITECTURE.md (header, §8–§13), README.md, CHANGELOG.md, docs/ROADMAP.md, misc/backlog.md, docs/investigations/delete-drift-investigation.md, docs/investigations/viewport-glitch-investigation.md, docs/dev-guides/live-repro-guide.md (§1), misc/before-you-release.md, misc/how-to-release.md, root AGENTS.md, .github/workflows/release.yml (awk extraction quoted verbatim), git log --oneline -50 (commit-style tally), directory listings of docs/ (architecture/, investigations/ incl. archives/, dev-guides/), misc/, misc/bug-repros/, LICENSES/.

§1 map re-verified 2026-07-09 against the docs/ reorg (investigation docs split into docs/investigations/ + docs/investigations/archives/; docs/live-repro-guide.md moved to docs/dev-guides/).

Maintain this skill when: a doc of record moves or splits (update the §1 map), ARCHITECTURE sections are renumbered (fix every § reference here), the CHANGELOG extraction in release.yml changes (§4 quotes it), or a new investigation doc establishes a better template. Keep the one-home-per-fact rule itself stable — it is the point of the skill.

防止对外宣传过度承诺的防火墙。用于审核README、博客等面向外部读者的内容,确保所有公开声明均可由仓库复现,严格区分已验证事实与候选特性,禁止在v1.0前夸大产品能力或宣称新颖性。
撰写面向外部的营销文案、博客或发布说明 对比竞品并决定可公开宣称的功能点 评估技术是否可标记为'新颖' 规划生态相关的公开消息传递
.agents/skills/edmund-external-positioning/SKILL.md
npx skills add I7T5/Edmund --skill edmund-external-positioning -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-external-positioning",
    "description": "Load when writing anything an outsider will read about Edmund — README edits, blog posts, release notes, marketing copy, social posts, webpage text, Show HN drafts — or when comparing Edmund to other editors (Typora, Obsidian, MarkEdit, Nodes), deciding what may be publicly claimed vs. what is still unproven, labeling a technique \"novel\", or planning ecosystem work (licenses, attribution, notarization messaging, appcast, issue templates, discovery listings). This skill is the overclaim firewall: what the positioning is, what evidence backs each claim, and what must exist before a claim gets stronger."
}

Edmund external positioning — what we claim, what we can prove

Governing rule: nothing may be claimed publicly that an outsider cannot reproduce from the repo + a release. Unproven = "candidate", never "novel" or "first". This skill exists to prevent overselling a beta.

When NOT to use this skill

You are doing Use instead
Actually cutting a release (tags, appcast, Sparkle, CI) edmund-release-and-operate
Internal docs, ARCHITECTURE.md, investigation chronicles edmund-docs-and-writing
Understanding the invariants / render pipeline itself edmund-architecture-contract
Deciding whether a code change is allowed edmund-change-control
Reproducing or debugging a bug edmund-debugging-playbook, edmund-live-repro-and-diagnostics
Judging research novelty for internal direction (not public claims) edmund-research-frontier, edmund-research-methodology
Verifying behavior before shipping edmund-validation-and-qa

1. The positioning (quote it exactly)

Source of truth: README.md. As of 2026-07-05:

  • One-liner: "Edmund is a minimal, file-based, native Markdown editor for macOS with inline live preview."
    • README carries its own TODO comment on this line: "Replace 'minimal' with 'customizable' or 'lightweight' once more features are implemented" — do not do that replacement early; "minimal" is the honest word today.
  • Goal statement: "Our goal is to be the CotEditor of Markdown editors, i.e. elegant, powerful, configurable, and native inside out."
  • Beta warning: "⚠️ Edmund is currently in beta." — this must stay visible in the README and any landing page until v1.0.0 ships.
  • Maintainer's blog post: https://i7t5.com/posts/2026-06-26-edmund/ ("more of the motivation and design philosophy"). Cite it; do not paraphrase or invent its content without fetching it.
  • Ambition framing: product-first. "Beyond state of the art" means product excellence — the TextKit 2 techniques are means, not ends. Never lead public copy with internal mechanism names; lead with what the user gets.

The six claimed differentiators (README, verbatim, 2026-07-05)

# Claim (verbatim)
1 "Live preview: Typora/Obsidian-style WYSIWYG."
2 "File-based: Open .md files from anywhere. No vaults or dedicated folders required."
3 "Native: 100% Swift. Based on AppKit and TextKit 2. No Electron. Minimal dependencies."
4 "Fast: Handles ~1-2MB files with ease. No launch lag."
5 "Extensible: Opt-in math and Obsidian syntax. Extensions system coming soon!"
6 "Private: Offline by default. Optional blocking of external links and HTML sanitization."

README also has a TODO comment after the list: "Move 'Fast' and 'Extensible'? Add 'integrations' section to Native after implementation" — the list is known-provisional; keep quotes synced to the file when you edit copy.

2. Claims discipline

Before strengthening any claim publicly, the evidence in the middle column must be upgraded to the right column. Status as of 2026-07-05.

Claim Current evidence Required before strengthening
Fast: "~1-2MB files with ease. No launch lag" Tests/EdmundTests/PerfHarnessTests.swift (gated MD_PERF=1, default 1.5MB doc, prints latencies; assertions are deliberately "sanity bounds, not budgets"); viewport-first lazy styling with fullLayoutMaxLength = 100_000 regime (EditorTextView.swift:80) A reproducible public benchmark: pinned document + hardware noted + numbers an outsider can rerun (MD_PERF=1 swift test). No comparative "faster than X" claims without benchmarking X the same way.
Extensible: "Extensions system coming soon!" Extensions API is docs/ROADMAP.md v1.0.0 — not shipped. Only opt-in math + Obsidian syntax exist today. README already hedges with "coming soon" Keep it hedged until the API + docs + at least one working extension ship. Never write "extensible via plugins" in present tense.
Private: "Offline by default" Grounded: Read webview disables JavaScript; all assets inlined as data URIs (math, icons, local images); remote images off by default (ReadRenderOptions.allowRemoteImages = false); inline HTML whitelisted via HTMLRenderer.sanitizeInlineHTML Note: the "Block external images" Settings checkbox is a misc/backlog.md item, not shipped; "optional blocking of external links" in README is forward-leaning — verify against code before repeating it elsewhere. Exceptions to name if asked: Sparkle update check, opt-in crash-log upload (§7 ARCHITECTURE).
Native: "100% Swift … No Electron. Minimal dependencies" True: SwiftPM, three deps (swift-markdown, SwiftMath, Sparkle) + vendored Lucide SVGs. Read mode is a WKWebView (system WebKit, JS off) — that is not Electron, but don't say "no web views" Nothing; this claim is safe. Just never inflate to "zero dependencies".
Live preview: "Typora/Obsidian-style" Shipped and demoed (README video, screenshots) Safe. Comparative feature-parity claims vs. Typora/Obsidian need a feature-by-feature check first.
File-based: "No vaults" Shipped by design Safe.
Beta status v0.1.3 (2026-07-04) Must stay visible everywhere until v1.0.0.

3. Novel vs. known — the honest inventory

When writing a craft blog post or comparison, keep this ledger straight. "Candidate" means blog-worthy pending proof; it is not "proven novel".

Known / prior art (never claim novelty here)

  • Live-preview Markdown editing: Typora, Obsidian, MarkText, Nodes. MarkEdit is the stated reference for source mode (ROADMAP v2.0.0 "the MarkEdit experience").
  • TextKit 2 viewport virtualization for Markdown: nodes-app/swift-markdown-engine (Apache 2.0, macOS 14+) solves the same problems — viewport virtualization, live styling, wiki links, reading column, LaTeX. Per ARCHITECTURE §14: consult it before inventing a new mechanism and as a technique source (drag-select autoscroll, overscroll). Its existence caps any "first TK2 live-preview engine" claim at zero.
  • The README's own Alternatives section credits ~15 editors. Public copy that ignores them reads as either ignorant or dishonest.

Distinctive candidates (label as such; each needs proof before publishing)

Candidate Why it might be blog-worthy Proof needed first
Attribute-only rendering with the storage == rawSource invariant (no attachment characters, no U+FFFC; delimiters hidden, never stripped; identity offset mapping) A clean architectural answer to the classic WYSIWYG mapping problem A survey showing how the named alternatives (incl. swift-markdown-engine) handle storage vs. display; the invariant's consequences demonstrated with runnable examples
Stroked-CGPath overlay workaround for the TK2 image wedge (image in a fragment overlay collapses the fragment's layout to one line; callout icon drawn as stroked path from vendored Lucide SVG instead) A concrete, reproducible TK2 bug + workaround — the classic useful engineering post A minimal frozen repro of the wedge outside Edmund; macOS version range where it reproduces
Bypassed-didChangeText heal + caret re-assertion (round-6 mechanism: TK2 leaves a _fixSelectionAfterChange queued after a bypassed edit; next-run-loop sync check heals storage and re-asserts the caret) Deep TK2 internals nobody has documented; the delete-drift chronicle (docs/investigations/delete-drift-investigation.md) already exists as raw material The frozen ReproScript repro kept green; behavior confirmed on current macOS before publishing (private-method behavior can change under us)
Diff-based undo restore that preserves TK2 layout (snapshot restore diffs rather than replaces, bypassing NSTextView undo) Practical fix for a visible TK2 pain (undo viewport yank) Before/after measurements (layout work saved, viewport stability) on a pinned document
In-process ReproScript methodology (-debug.reproScript, keystroke replay without CGEvents/TCC; docs/dev-guides/live-repro-guide.md) Reusable testing methodology for any AppKit text app Show it reproducing a real bug end-to-end in a fresh checkout; that IS the reproducibility standard

Reproducibility standard for any technical post: a reader with the repo and the post must be able to reproduce every claim — frozen repro scripts, pinned document fixtures, named macOS versions, measured numbers with the command that produced them. If a claim can't meet that, cut it or mark it anecdotal.

4. Ecosystem and license hygiene

Verified against the repo, 2026-07-05:

  • License: Apache 2.0 (LICENSE, README "License" section, and the 0.1.0 changelog entry all agree). Say "Apache 2.0", never "MIT".
  • Lucide icons: vendored, ISC (LICENSES/lucide.txt, © 2026 Lucide Icons and Contributors; parts derived from Feather). Attribution duty: keep LICENSES/lucide.txt shipping and credit Lucide where icons are discussed.
  • Why Lucide in both modes (SF Symbols constraint): ARCHITECTURE §6 — SF Symbols cannot ship in exported PDFs (license), so callout headers use Lucide in both Read (inline SVG, currentColor-tinted) and Edit (rasterized tinted NSImage overlay). App-chrome SF Symbols (toolbar/settings) are fine; Edit-mode task checkboxes still use SF Symbols on-screen only. Don't "simplify" copy or code in a way that breaks this split.
  • Dependencies to credit: swift-markdown, SwiftMath, Sparkle (README "Dependencies"). Acknowledgements section additionally credits swift-markdown-engine/Nodes, Typora, theme sources, create-dmg, MarkEdit, and others — preserve it when restructuring the README.
  • Not notarized (2026-07-05): ad-hoc signed; users hit Gatekeeper ("damaged app"). README's WARNING block gives the two workarounds (System Settings → Privacy & Security → Open Anyway; or xattr -dr com.apple.quarantine /Applications/Edmund.app, maybe sudo). Keep those instructions accurate in every venue that mentions installing. misc/marketing/MARKETING.md gates Show HN on fixing this (notarize, or make the workaround idiot-proof).
  • GitHub issue templates exist: .github/ISSUE_TEMPLATE/bug_report.md, feature_request.md. Point users there, not at email.
  • appcast.xml is a public artifact served raw from the repo (SUFeedURL points at the raw GitHub URL). Anything committed to it is user-visible in Sparkle's update dialog. Pipeline details: edmund-release-and-operate.

5. Release-notes and public-writing style

  • Pipeline: CHANGELOG.md sections become both the GitHub release notes (awk-extracted) and Sparkle's update-dialog HTML (scripts/changelog-to-html.py → appcast <description>). A CHANGELOG entry IS public copy — write it that way. Mechanics: edmund-release-and-operate.
  • Actual house style (read CHANGELOG.md 0.1.0–0.1.3 before writing): Keep-a-Changelog headers (### Added / Changed / Fixed); one line per item, sentence case, no trailing period enforced; links to issues ([#156]) and investigation docs (([docs](docs/investigations/delete-drift-investigation.md))); user-visible phrasing ("Redo now jumps to where changed text was instead of caret") not internal jargon; occasional first-person maintainer notes with personality ("trying to have Fable 5 fix all the big bugs while I still have it with me"); 0.1.0 used bold Feature — one-line descriptions. Match this voice: plain, specific, lightly informal, zero hype.
  • Screenshots/videos: README embeds live in docs/assets/ (v0.1.0_*.png, installation.png, v0.1.0_video.mp4, AppIcon/). Raw/source marketing assets live in misc/marketing/: MARKETING.md (the plan), reddit-post.md, demo-slide-v0.1.key, demo-src-files/, demo videos (demo.mov, demo-video-v0.1-brown.mp4), _raw screenshot masters, social-preview_figma.png. New public screenshots: polished copy → docs/assets/, raw master → misc/marketing/, versioned filenames.

6. Marketing priority context (2026-07-05)

From misc/backlog.md "Now": "Priority: Marketing = Bugs >= UI/UX > Features" — marketing work is tied for top priority with bug fixes. Open marketing items: screenshots/files and a webpage (reference: kruszoneq.github.io/macUSB). Backlog embeds a star-history.com chart; misc/marketing/MARKETING.md names GitHub stars (~69 at writing) as the goal and metric, audience "developers who value craft", and holds Show HN in reserve until first-run friction and a landing page are fixed. Its "craft months" deep-dives are exactly the Section 3 candidates — which is why the proof bar there matters.

Provenance and maintenance

  • Sources verified 2026-07-05 against: README.md, docs/ROADMAP.md (last updated 2026-07-03), misc/backlog.md, docs/ARCHITECTURE.md (§2, §6, §8, §13, §14), CHANGELOG.md (0.1.0–0.1.3), LICENSE, LICENSES/lucide.txt, .github/ISSUE_TEMPLATE/, misc/marketing/, Tests/EdmundTests/PerfHarnessTests.swift, Sources/EdmundCore/Export/{ReadRenderOptions,HTMLRenderer,DocumentHTML}.swift, Sources/EdmundCore/TextView/EditorTextView.swift.
  • Volatile facts are date-stamped inline: version (0.1.3), beta status, notarization status, shipped-vs-roadmap feature split, star count, README wording. Re-verify each against the file before repeating it publicly.
  • When README differentiators or the one-liner change, update the verbatim quotes in §1 and re-run the §2 evidence check.
  • If a §3 candidate ships as a published post, move it out of "candidate" and link the post + its frozen repro.
  • Cross-references: edmund-release-and-operate (release/appcast mechanics), edmund-docs-and-writing (internal doc style), edmund-research-frontier (novelty judgment for research direction), edmund-architecture-contract (the invariants quoted here).
记录 Edmund Markdown 编辑器历史重大 bug 调查、死胡同及被撤销修复的档案。用于避免重复排查已知问题,涵盖光标漂移、视图滚动及渲染异常等场景,提供症状、根因、证据及禁止重试项。
重新调查光标/选择区症状(如漂移、不同步) 排查视图/滚动故障(如卡顿、震荡) 分析渲染阻塞或显示异常 评估可能已被尝试并撤销的修复方案 遇到似曾相识的 Bug 报告 测试通过但应用行为异常时 疑惑代码中奇怪的保护逻辑或断言
.agents/skills/edmund-failure-archaeology/SKILL.md
npx skills add I7T5/Edmund --skill edmund-failure-archaeology -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-failure-archaeology",
    "description": "The chronicle of every major bug investigation, dead end, rejected fix, and revert in the Edmund Markdown editor. Load BEFORE re-investigating any caret\/selection symptom (drift, jump, desync), any viewport\/scroll glitch (lurch, oscillation, wrong landing, can't-scroll-up), any rendering wedge (clipped wrap, one-line collapse, blank space), or any release\/update failure (signing, appcast, Sparkle \"improperly signed\"). Load before proposing a fix that might already have been tried and reverted, when a bug report \"looks familiar\", when a test passes but the live app still misbehaves, or when wondering why the code does something weird (a guard, a re-assert, a deliberately-missing icon). Every entry: symptom, root cause, evidence (commit hashes, docs), status, and what NOT to retry."
}

Edmund failure archaeology

Chronicle of settled battles. Purpose: nobody re-fights one. Each entry gives symptom → root cause → evidence → status. Hashes are on main unless noted. Dates are commit dates. Status vocabulary: settled (root-caused, fix verified), mitigated-unconfirmed (fix shipped, never seen killing a live occurrence), open (in misc/backlog.md), reverted-pending-redo.

When NOT to use this skill

  • Designing a change / asking "why is it built this way" → edmund-architecture-contract.
  • Actively debugging a NEW symptom (method, not history) → edmund-debugging-playbook.
  • Driving the live app to reproduce something → edmund-live-repro-and-diagnostics (and docs/dev-guides/live-repro-guide.md).
  • TextKit 2 / AppKit API semantics → textkit2-appkit-reference.
  • Cutting or fixing a release → edmund-release-and-operate (this file only records how 0.1.0 broke).
  • Build environment, stale-link traps in depth → edmund-build-and-env.
  • Deciding whether a change is safe to make at all → edmund-change-control.
  • Test strategy / what the suite can and cannot catch → edmund-validation-and-qa.
  • The ongoing caret-integrity program (forward-looking) → edmund-caret-integrity-campaign.
  • Debug flags (-debug.reproScript, verbose tracing toggles) → edmund-config-and-flags.

1. The delete-drift saga (rounds 1–6) — issue #156

The hardest bug in the project's history: pressing Delete moved the caret to a different line instead of deleting. Six rounds, 2026-06-25 → 2026-07-04. Full trail: docs/investigations/delete-drift-investigation.md (read it before touching +EditFlow / +SelectionTracking / the heal). One symptom, FOUR distinct root causes stacked on top of each other — each fix was real, and each round's recurrence was a different mechanism underneath.

STATUS: settled through round 6 (shipped in 0.1.3, 2026-07-04). The class — live-only caret/selection desync — remains the project's hardest problem; new rounds are possible. Backlog still lists "Delete caret drift" under On-going bugs as a class to watch, not a known unfixed defect.

Round 1 — stranded IME marked text (2026-06-26, 386604b + docs ef3d87e)

  • Symptom: once it started, EVERY delete drifted; never at launch; cleared by switching apps and back. Text stayed correct — caret-only desync.
  • Root cause: every styling path bails on hasMarkedText() (correct during live IME composition). A stranded composition (hasMarkedText() stuck true, no live composition) made didChangeText bail forever → rawSource/blocks froze while storage kept mutating → all caret math ran against a stale model. Strander: the async active-block restyle in +SelectionTracking re-checked isUpdating but not hasMarkedText(), so it could run recomposeDirty over a live composition scheduled one turn earlier.
  • Fix: (a) add the missing !hasMarkedText() guard to the async restyle; (b) becomeFirstResponder recovery hook — unmarkText() + resync when the invariant is broken on focus regain (made the user's accidental focus-switch cure deterministic).
  • Why it came back: the guard closed one strander; other marked-text sources existed (round 2), and other desync mechanisms entirely (rounds 4–6).

Round 2 — marked text without "IME" (2026-06-27, a1f3219)

  • Symptom: recurred with no CJK/accent/emoji input. Focus-switch still cured it.
  • Root cause (by elimination, documented in the doc): still stranded marked text — from automatic text completion / inline predictions, which inject provisional marked text on plain typing.
  • Fix: isAutomaticTextCompletionEnabled = false, inlinePredictionType = .no in commonInit, plus a permanent Log.info breadcrumb in the recovery hook.
  • Why it came back: the next recurrence wasn't marked text at all.

Round 3 — no fix; built diagnostics instead (2026-06-28, 5dae387, 3aaeb04, PR #139)

  • Symptom: recurred on a build with rounds 1–2. NO recovered stranded desync log line; a headless probe of the exact gesture showed the model was CORRECT. Only appeared after minutes of editing in one window.
  • Conclusion: the model/parse layer is sound; the drift is a live NSTextView / TextKit 2 / input-context phenomenon invisible headless. Chasing it blind was declared the wrong move.
  • Shipped: verbose editor tracing (Settings ▸ Advanced, Log.trace, category .edit, per-event live-state prefix) + an always-on O(1) invariant tripwire (verifyEditorInvariants, logs error on length mismatch). This instrumentation is what cracked rounds 4–6. Lesson: when a live-only bug resists reproduction, ship diagnostics, not guesses.

Round 4 — drag-move deletes with NO didChangeText (2026-07-02, 9f99795, PR #163)

  • Symptom (from the round-3 trace): drifting deletes showed shouldChangeTextnothingselectionDidChange mid-recompose with a stale caret. Origin event: a drag-select, then shouldChangeText OK repl="", then LEN-MISMATCH forever — didChangeText never fired.
  • Root cause: AppKit's drag-move gesture, when the drop lands past the end of the document (or fumbles), performs the source deletion via shouldChangeTextreplaceCharacters and never calls didChangeText. rawSource silently froze — and autosave wrote the stale bytes: this was a data-corruption bug, not just a caret bug.
  • Fix: shouldChangeText schedules a next-run-loop bypass check (RunLoop.main.perform): a pendingEdit still unconsumed one pass later == didChangeText was bypassed → run the same sync it would have, log healing storage edit that bypassed didChangeText (release too). Exempt while isUpdating/isUndoRedoing/hasMarkedText() (IME legitimately defers).
  • Why it came back: the heal restored the model but rounds 5–6 found the heal itself could move the caret.

Round 5 — the heal leaped the caret (stale selection) (2026-07-03, 422498f, docs c4a602b, merged 222dd86, PR #166)

  • Symptom: heal fired, invariant restored, bytes correct — but the caret leaped to the END of the document at the heal moment.
  • Root cause: when the bypassed deletion removes the selected text, AppKit also skips its usual selection fix, so at heal time the selection still spans deleted text (e.g. {951,37} in a 973-char doc). The heal's restyle makes AppKit re-resolve the invalid selection → clamps to document end.
  • Fix: before syncing, the heal collapses an out-of-bounds selection to the edit point. (Headless NSTextView clamps this itself — the test documents intent; only live layout reproduces the leap.)
  • Why it came back: this out-of-bounds clamp was a special case of the real mechanism, found in round 6.

Round 6 — TextKit 2's queued selection fixup: the drift mechanism itself (2026-07-04, 1b1420a, branch fix/wrapped-paragraph-caret-drift, merged 218d922, PR #169)

  • Symptom: typing mid wrapped paragraph, one backspace leaped the caret +43 ("two viewport-lines down"); drift no longer continuous — one delete drifts, the next ones don't. Model fine; a heal had fired 80 seconds EARLIER.
  • Root cause (named via a traceSelectionOrigin stack capture): a normal edit runs TextKit 2's _fixSelectionAfterChangeInCharacterRange synchronously inside its own transaction. A didChangeText-bypassing mutation skips that too, so the fixup stays queued and fires at the NEXT endEditing — the heal's attribute-only restyle — where it maps the stale selection against post-edit coordinates and drops the caret blocks away. Fires exactly once (state is then synchronized), explaining "drifts once, then fine". Round 5's clamp was the sub-case where the stale selection ran past the shrunk document end.
  • Fix: the heal derives the correct caret from the pendingEdit hull and sets it both before AND after syncRawSourceFromDisplay(). The before-only version still leaped — the queued fixer moves even a freshly set, fully valid caret during the sync's endEditing. The post-sync re-assert is the load-bearing half; the pre-set keeps cursorRaw/active-block styling correct.
  • The breakthrough repro (first deterministic one in six rounds): ReproScript.swift (DEBUG-only, -debug.reproScript <path>) replays keystrokes in-process through window.sendEvent(_:) — no TCC, works on an invisible Space. bypassdelete <needle> simulates the drag-move deletion exactly; one bypass beforehand → the next delete always drifts. Typing alone never drifts. See edmund-live-repro-and-diagnostics.

Do not retry (proven dead across the saga)

  • Headless/unit tests for this class. The test harness runs TextKit 2's selection fixups synchronously, so the deferred-fixup state never forms. The round-6 unit test passes with and without the fix — it is a contract spec, not a regression guard. Only the ReproScript live repro discriminates. Do not "add a test to catch it" and call the class covered.
  • CGEvent injection as the default live driver. Round 6's session dropped the events (per-session TCC), and the app's windows launch on an inactive Space. Use ReproScript first.
  • DEBUG assertion in didChangeText's marked-text guard — rejected in round 1: the invariant is legitimately broken during composition; it false-fires on every IME keystroke.
  • "No explicit selection repair needed" (round 4's claim) — wrong twice. Any new heal-like path must handle selection explicitly, before and after.
  • swift build trusted after "Build complete!" — round 6 hit a stale-link relink failure TWICE; two "failed" fix iterations were phantoms running old code. Verify with strings on a LONG literal (≤15-byte literals inline on arm64 and never show), cure with swift package clean, never hand-delete edmd.build/. Details: edmund-build-and-env.

2. Undo/redo viewport drift — the costliest failure

STATUS: settled (2026-07-02, 5bb2b40, part of PR #164) — with one caveat: misc/backlog.md "Lurking (Unreproduceable)" carries a later note "Undo/Redo and Copy/Paste scrolling is failing again". No repro exists. Treat the mechanism below as settled and any new report as a NEW investigation that starts from this entry.

  • Symptom: undo scrolled too far down; redo centered on wherever the caret sat before the undo; changed text never selected.
  • Root cause (two defects, found by code read before any experiment):
    1. restoreSnapshot ran a full recompose — replacing the entire storage discards every TextKit 2 layout fragment, resetting ALL geometry to height estimates; the subsequent centering math measured estimates.
    2. performUndo recorded the redo snapshot with the caret at undo invocation time (stale), and redo centered on it.
  • Fix: textDiff(old:new:) single contiguous changed span → range-bounded recomposeReplacing (layout outside the span stays real); the changed range — never a stored caret — is selected and drives the viewport (hold if visible, else center).
  • Load-bearing contract: never full-recompose on undo/redo. Anyone "simplifying" restoreSnapshot back to recompose reintroduces the bug. Guarded by TextDiffTests, UndoRedoSelectionTests, UndoRedoViewportTests.
  • Prior art that treated symptoms without naming the estimate problem: 9aaa11b (undo hold-or-center), 2778d6e/21cc284 (cursor-move lurch), 84123e4 (pin scroll above viewport), c49cd5c (lazy viewport-first styling). All sound, all workarounds; 5bb2b40 removed the manufactured estimates at the source.

3. Viewport glitches — TextKit 2 height estimates (PR #164, 2026-07-02)

Full trail: docs/investigations/viewport-glitch-investigation.md. Three reported symptoms, one root cause: every off-screen TextKit 2 frame is an estimate; code that discards layout, trusts off-screen y, or runs two scroll policies at once turns estimate churn into visible jumps. Community-documented (Krzyżanowski / STTextView, Apple forums) — even TextEdit exhibits it.

  • Bug 1 (undo/redo): entry 2 above. STATUS: settled (same caveat).
  • Bug 2 (editing at top pushes line 1 above the viewport, can't scroll up). Theory: TK2 assigns fragments negative y when estimates above the viewport correct downward; scroller clamps at 0. Mitigations 217da5f (documents ≤100k UTF-16 kept fully laid out via a deferred scheduleFullLayoutSettle — estimates never exist) and 8b4ecfe (repairContentAboveOrigin: first fragment minY < -0.5 → re-lay start→viewport-end inside preservingViewportAnchor; breadcrumb repairing content above origin). STATUS: mitigated-unconfirmed. The doc is explicit: Bug 2 was never reproduced live; the repair had not been confirmed against a live occurrence as of this writing (2026-07-05). If it recurs: grep ~/.edmund/logs for the breadcrumb — present means diagnosis confirmed but repair raced/undersized; absent means different cause (scroller-only estimate jumps, or textContainerOrigin).
  • Bug 3 (viewport oscillates during a steady drag-select). Two scrollers fighting: drag autoscroll pulling down vs the scrollRangeToVisible override always revealing the selection top once the selection outgrew the viewport. Fix 340fcbc: reveal the nearest end. STATUS: settled by geometry/reasoning — a live drag was never synthesized (that session couldn't arm AppKit selection). Phase 1 of the same report ("can't select") was NOT a bug: a whole-doc selection was active, so the drag was AppKit's drag-move gesture — same family as delete-drift round 4.
  • Do not retry: raising fullLayoutMaxLength without measuring ensureLayout cost (full layout on large docs is the process-killing path that motivated the lazy pipeline); running a full layout inside a caller's preservingViewportAnchor (poisons its before/after measurement — the tab-indent stability test caught a 366pt compensation; that's why the settle is deferred).
  • Backlog keeps "Inaccurate viewport estimates and things related" under On-going bugs, plus a lurking "glitch when scrolling" — the estimate class is managed, not extinct. Roadmap v1.0.0 carries "TextKit 2 viewport stabilization".

4. Callout-title wrap / the image wedge (settled 2026-07-03, aa45563 + ae61644, PR #165)

Full trail: docs/investigations/archives/callout-title-wrap-investigation.md — including a 10-row matrix of dead ends.

  • Symptom/goal: custom callout titles (> [!type] Title) should render as real wrapping text with the type icon; any attempt to draw the icon clipped the wrapped title to one line.
  • Root cause: drawing any IMAGE on a multi-line layout fragment wedges that fragment to a single line — an unexplained TextKit 2 reentrancy quirk. Isolated exhaustively: fragment overlay, frame-relative draw, before/after super.draw, raw CGContext.draw, editor-level draw(_:), pre-rasterized bitmap, transparent subview, layer-backed subview, CALayer contents — ALL clip. Controls: no icon → wraps; positions computed but plain rect filled instead of the image → wraps. Reading layout is fine; drawing a shape is fine; drawing an image is not.
  • Fix: the icon is a stroked CGPathSVGPath parses the vendored Lucide geometry, FragmentOverlay gained a path form, DecoratedTextLayoutFragment strokes it in CG. Verified live: icon renders, long titles wrap and re-wrap on resize.
  • Standing constraint: any future overlay that can share a line with wrapping text MUST use the path form, never the image form. Existing image overlays (math, bullets, default callout header) survive only because they sit on single-line fragments.
  • Do not retry: any image-drawing mechanism from the matrix; bumping the deployment target to macOS 15 on the hope newer TextKit 2 fixed it (no evidence, drops Sonoma incl. the dev machine). The whole-header-as-image alternative is preserved on branch fix/callout-title-image (tip c7f5170, unmerged): it sidesteps the wedge but has two unsolved problems (~2× line height band above the title; a width-timing race).
  • Still open nearby (backlog): callout icon baseline / crispness; the callout-at-end-of-file extra colored line (live-path rendering bug).

5. The 0.1.0 release failures (2026-06-26 → 2026-07-02)

Reference: ARCHITECTURE §13. Five separate failures shipping and updating the first releases. STATUS: all settled, with one time bomb (PAT expiry).

  1. sign_update -s exits 1 for new keys (59565f5, 2026-06-27, PR #135). Sparkle deprecated -s <key>; for keys generated after that change it prints a warning and exits 1. Killed the first 0.1.0 release. Fix: key on stdinecho "$SPARKLE_ED_PRIVATE_KEY" | sign_update --ed-key-file - <dmg>. Do not retry -s.
  2. Bundle not sealed → EVERY update failed "improperly signed" (5e54b40, 2026-06-29, issue #158). Sparkle re-validates the Apple code signature at install (SUUpdateValidatorSecStaticCodeCheckValidity); the build signed only the main binary, never sealed the bundle, so a valid EdDSA signature didn't save it. Fix: codesign --deep the whole .app — and because SwiftMath's resource bundle must sit at the .app root (Bundle.module hardcodes Bundle.main.bundleURL) and codesign won't seal a bundle with root items, seal first, copy the SwiftMath bundle in AFTER sealing. The lone unsealed root item trips strict codesign --verify but not Sparkle's non-strict check (verified against that exact API). Do not "fix" the ordering or the failing strict verify.
  3. Appcast push to protected main rejected, GH006 (e56a4dd, 2026-06-28, PR #140). GITHUB_TOKEN isn't admin; enforce_admins: false means an admin PAT bypasses the required check. Fix: fine-grained admin PAT in secret RELEASE_TOKEN, set on the checkout step (not the push URL — actions/checkout persists an extraheader credential that overrides inline-URL creds). RELEASE_TOKEN expires 2027-06-27; rotate before then or releases fail at the appcast push.
  4. create-dmg quirks (098d8c0, 2026-06-26, documented in §8): it's the npm create-dmg (sindresorhus), not the Homebrew tool; Node ≥20; exit code 2 for unsigned images is success; space-in-filename normalization.
  5. Release workflow YAML invalid (854f85d, 2026-07-02, merged 232e6c8, PR #162). Literal multi-line bash strings inside a run: | block had unindented lines — invalid in a YAML block scalar; the whole workflow file failed to parse. Fix: build the strings with printf \n escapes; also $(...) strips trailing newlines, so the separator newline lives in NEW_ITEM's format string, not DESC_BLOCK.

6. Reverts and abandoned directions

  • Selection tintee173f7 (2026-06-26): reverted an experimental selection color back to accent-derived (accent @ 30% alpha). STATUS: settled. Don't re-hardcode a bespoke selection color.
  • img.md-image { display:block } in the export/Read HTML theme75d2824 (2026-06-25): reverted; it did NOT fix the image blank-space and broke layout. The commit title itself records the verdict: image blank-space is a separate, STILL-OPEN bug (backlog: "Attached image padding… creates a large empty space below", misc/bug-repros/image-blank-after.mov). Do not retry display:block for it.
  • Checkbox click-to-toggle + table borders9991413 (2026-06-03): pulled from a branch for separate passes. STATUS: partially redone. Table work landed later (edit-mode table alignment shipped, per backlog Done); Read-mode click-to-toggle checkbox is still in the v1.x backlog — reverted-pending-redo. The original attempt lives on stale branch feature/checkbox-toggle (merged history, d6227e3).
  • TextKit 2 migration regressionsb3a4b29 (2026-06-12): the TK1→TK2 migration silently broke inline-math height, HR spacing, and scroll; fixed with RenderingRegressionTests as the guard. Lesson: TK2 migrations regress silently in geometry — extend that suite when touching layout.
  • Stale branches that look abandoned but are MERGED (don't "rescue" them): feature/incremental-recompose (tip 1222f79, in main) and refactor/word-level-rendering (merged via PR #8, 7eb6a21 — word-level delimiter hiding became the shipped approach). The genuinely unmerged WIP branches are fix/callout-title-image (entry 4) and dozens of old merged topic branches never deleted.

7. Wrapped-paragraph caret drift (PR #169) — same battle as round 6

fix/wrapped-paragraph-caret-drift / merge 218d922 IS delete-drift round 6 (entry 1): the branch name comes from the reporting symptom (backspace mid wrapped paragraph), but the root cause was the queued TextKit 2 selection fixup armed by an earlier bypassed drag-move edit — the wrapped paragraph was incidental. STATUS: settled with 1b1420a. If a caret drift is reported "in a wrapped paragraph", do not assume wrapping is the mechanism; check for a preceding heal breadcrumb in ~/.edmund/logs first.


8. Smaller settled battles (one paragraph each; verified in git)

  • Flaky math fit-width test — took TWO rounds (5421297 2026-06-06 branch fix/flaky-math-test, then 352bdc9 PR #65). First fix pinned the text container width (tracking off) — the flake persisted ~1 in 3 runs. Real cause: shared theme-defaults state pollution between tests; fixed with isolated defaults. Lesson: a flake "fix" that doesn't name the shared state isn't done.
  • Emoji rendered as missing-glyph boxes (0f5ffba, 2026-06-06). EditorTextStorage.fixAttributes is a deliberate no-op (so .attachment on real characters survives) — which also disabled font substitution. Fix: perform substitution manually (Apple Color Emoji per composed-character sequence, ZWJ/skin-tone graphemes whole). Don't re-enable the framework fixAttributes; it strips the marker attachments.
  • Nested list hanging indent (8d2088f, 2026-06-06, PR #64). swift-markdown's list-item delimiter excludes leading indentation; the visible spaces broke the hang. Fix: hide the leading whitespace in the inactive branch; indentation comes entirely from the paragraph style.
  • Ordered-list deep nesting lost styling (b255903, 2026-06-06). swift-markdown parses ≥4-space indent as indented code; the rescue regex only matched [-*+]. Extended to \d{1,9}[.)]. Any new list-ish syntax must be added to the rescue parser too.
  • Window size persistence — three commits to get right (538ff6ed967bcf678c5d6, 2026-06-28, PR #144). Saving content-view size made windows grow taller on reopen (titlebar double-counted); final form stores the full window frame and restores via setFrame.
  • Toolbar right-click interception — three failed view-level attempts (4eb604a, 6723280, then f8472ca, 2026-06-26). View .menu, rightMouseDown, and a gesture recognizer all lost to the toolbar's "Customize Toolbar…" menu. Working fix: DocumentWindow overrides NSWindow.sendEvent — the documented funnel ahead of the toolbar — and swallows secondary clicks on the view-mode button. Do not retry view-level interception for anything the titlebar/toolbar claims.
  • Invisible CJK input (a3df387): IME-composed text was invisible until committed; fixed by keeping marked text visible. Related to (and predating) the round-1 marked-text rules.

9. Still OPEN — do not let this chronicle imply otherwise

Per misc/backlog.md (cross-checked 2026-07-05): footnotes don't render (edit or Read); math doesn't render in Read mode; math padding in edit mode; image blank-space below attached images (see the 75d2824 revert); callout-at-end-of-file extra colored line; max content width not applied to Read mode; tables don't wrap/shrink at small content sizes; table-cell content wraps out of the cell; "sometimes click to select / select+delete doesn't work" (unreproduced); lurking scroll glitch from off-viewport height changes; lurking indented-cursor-stuck report; lurking "undo/redo and copy/paste scrolling failing again" (see entry 2 caveat). Delete-caret-drift and viewport-estimate classes stay on the watch list even though every known mechanism is fixed.


Provenance and maintenance

  • Written 2026-07-05 by mining: docs/investigations/delete-drift-investigation.md, docs/investigations/viewport-glitch-investigation.md, docs/investigations/archives/callout-title-wrap-investigation.md, docs/ARCHITECTURE.md §13, CHANGELOG.md, misc/backlog.md, docs/ROADMAP.md, and git log --all (every hash above verified with git show on that date).
  • Code and git win over prose. If this file disagrees with a commit or the current source, trust the commit, then fix this file.
  • Update triggers: a new delete-drift round (append to entry 1 — never a new doc); any live confirmation or refutation of repairContentAboveOrigin (flip entry 3 Bug 2 off mitigated-unconfirmed); RELEASE_TOKEN rotation (entry 5); any revert (entry 6); closing a backlog bug named in entry 9.
  • Keep the status vocabulary exact; "mitigated-unconfirmed" is not "fixed". No oversell — this file's value is that its claims can be trusted blind.
  • Sibling map lives in "When NOT to use" above; keep it in sync as the skill library grows.
用于测量和诊断 Edmund 应用中的实时行为缺陷(如光标、IME、拖拽)。提供从单元测试到脚本驱动及 CGEvent 的复现阶梯,指导如何读取诊断日志、使用 ReproScript 驱动应用及截图测量像素,解决无头测试无法复现的问题。
涉及实时行为(光标、IME、拖拽、视口时序)的 Bug 单元测试无法复现的报告 需要读取诊断追踪数据 需要通过脚本按键驱动运行中的应用 需要从截图中测量像素
.agents/skills/edmund-live-repro-and-diagnostics/SKILL.md
npx skills add I7T5/Edmund --skill edmund-live-repro-and-diagnostics -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-live-repro-and-diagnostics",
    "description": "How to MEASURE Edmund instead of eyeballing it — the diagnostic tools, interpretation guides, and working scripts. Load when a bug involves LIVE behavior (caret, IME, drag, viewport timing), when a unit test cannot reproduce a report, when you need to read diagnostic traces, drive the running app with scripted keystrokes, or measure pixels from a screenshot. Contains the repro escalation ladder, the ReproScript driver, the CGEvent fallback, and screencapture measurement, plus scripts\/ helpers. Not the symptom→mechanism table (edmund-debugging-playbook), not the campaign (edmund-caret-integrity-campaign), not build hygiene (edmund-build-and-env)."
}

Edmund live repro & diagnostics

A class of Edmund bugs lives in the live NSTextView / TextKit 2 / input-context layer (deferred selection fixups, IME composition, drag sessions, event-loop timing). Headless tests cannot form the broken state — the test harness runs AppKit's deferred machinery synchronously, so a green unit test proves nothing about this class. This skill is how you make such a bug cheap to observe, then deterministic. Primary source doc: docs/dev-guides/live-repro-guide.md.

Verified 2026-07-05.


0. Safety preamble (do this every time)

  • Check for the user's live instance first. The user's daily-driver app has the same binary name (edmd). Run scripts/check-live-instance.sh. Never blanket pkill -x edmd — kill only your own PID, or use pkill -f EdmundDbg (only your debug bundle matches).
  • Recreate the test document fresh before every run — autosave mutates it; run 2 against run 1's leftovers produces garbage offsets.
  • Verify the binary is fresh before trusting a run (SwiftPM sometimes prints Build complete! without relinking edmd) — strings/shasum method in edmund-build-and-env.
  • Do not request macOS Computer Access. Screen Recording + Accessibility are already granted for this project; ReproScript (§3) needs neither.

1. The escalation ladder

Work down; stop at the first level that reproduces. Each is more faithful and more expensive than the one above.

# Technique Faithful to Cost Use when
1 Plain unit test (makeEditor()) model/parse/style logic seconds anything not event-timing-dependent
2 Windowed unit test (NSWindow + NSScrollView, real deleteBackward(nil)) + layout, viewport, first responder seconds viewport/lazy-styling bugs (LazyRenderingTests setup)
3 In-process ReproScript (§3) + real key path, run-loop pacing, real process ~1 min/run anything keyboard/edit-pipeline shaped — the default for live bugs
4 CGEvent driver (§4) + real HID events, real mouse (drags!) TCC-dependent mouse-only paths: drag-select, drag-move, autoscroll
5 Instrumented field occurrence everything days can't trigger it — instrument first (§2), decide on the next hit

Two levels deserve emphasis:

  • Level 2 failing to repro is evidence, not defeat — it tells you the bug needs deferred/queued AppKit state, pointing you at level 3–4.
  • Level 3 exists because level 4 is unreliable — background/agent sessions often have no TCC grant and synthetic keyboard events get dropped silently. In-process injection needs no permission.

2. Step 0 — make the trace tell you the trigger

Never script blind. The recipe is usually already in ~/.edmund/logs, if verbose diagnostics were on. Launch flags (file arg must be argv[1]):

<app>/Contents/MacOS/edmd FILE.md \
  -settings.general.diagnosticLogging YES \
  -settings.advanced.verboseEditorDiagnostics YES

Trace-field decoder (each verbose line carries these; from EditorTextView+Diagnostics.swift):

Field Meaning
sel current selection {location,length}
active active (caret) block index
marked is there marked/IME text
up isUpdatingup=Y = event arrived mid-recompose (suspicious)
undo undo-stack depth
blocks block count
storLen / rawLen storage length vs rawSource length
  • Healthy edit ordering: shouldChangeTextselectionDidChange (up=N) → synced. A transient ⚠︎LEN-MISMATCH between those lines is normal (storage moves before rawSource syncs).
  • Suspect: a selectionDidChange with up=Y at a surprising position; a persisting LEN-MISMATCH; a shouldChangeText with no synced/SKIPPED/DEFERRED after it (a bypassed didChangeText); the healing storage edit that bypassed didChangeText breadcrumb.
  • traceSelectionOrigin logs a call stack for any selection change that lands mid-recompose — this is what named _fixSelectionAfterChange in round 6.
  • Walk BACKWARDS from the first bad line, not forwards from the symptom. The round-6 drift was armed ~80 seconds and dozens of healthy edits before the visible failure. The user-visible symptom is often the second half.

If the log didn't capture the deciding fact, add the log line first (keep good ones behind Log.shouldTrace and ship them) and reproduce again. Also reconstruct the document — wrapped-paragraph geometry, block boundaries, and block kinds all matter; repro against a lookalike, never "hello world".

scripts/grep-trace.sh [YYYY-MM-DD] surfaces the suspect patterns in one shot.


3. The in-process ReproScript driver (default for live bugs)

Sources/edmd/App/ReproScript.swift, DEBUG builds only. Replays a keystroke script against the front document by synthesizing NSEvents and pushing them through window.sendEvent(_:) — the full authentic key route (keyDown → interpretKeyEventsinsertText: / deleteBackward:). No Accessibility, no visible window required (works on an inactive Space), real run-loop pacing.

Launch: scripts/launch-debug.sh FILE.md SCRIPT.repro (assembles EdmundDbg.app, guards the user's instance, direct-execs with all flags). Or by hand:

build/EdmundDbg.app/Contents/MacOS/edmd "$DOC.md" \
  -settings.general.diagnosticLogging YES \
  -settings.advanced.verboseEditorDiagnostics YES \
  -debug.reproScript "$SCRIPT.repro" \
  -ApplePersistenceIgnoreState YES &

Command surface (one per line, # comments allowed):

Command Effect
sleep <ms> wait before the next command
caret <needle> place caret before the first occurrence of <needle>
type <text> one real key event per char, ~80 ms apart
backspace <n> n real delete keystrokes, ~300 ms apart
bypassdelete <needle> simulate the drag-move source deletion: select range, shouldChangeText + storage mutation, no didChangeText
assertcaret <needle> log repro assertcaret PASS/FAIL sel=… want=… iff caret sits exactly before <needle>
logsel log selection, rawSource length, doc count

Round-6 minimal repro (the worked example): the deciding output was logsel 321 (broken) → 290 (fixed), every run, window not even visible.

sleep 2000
bypassdelete Sizemore,
sleep 800
logsel            # broken: {321,…}; fixed: {290,…}
backspace 2
logsel

Design rules — keep them when extending:

  • Address text by needle, never offset — offsets go stale the moment a script edits; needles survive (this is what makes soak scripts possible).
  • Real events over direct method callsinsertText("") shortcuts skip deleteBackward's selection machinery, the exact place round 6 lived.
  • Simulate AppKit-internal paths by exact call sequencebypassdelete replicates shouldChangeTextreplaceCharacters, no didChangeText verbatim, not an approximation. Pin any new internal path's real sequence from a traceSelectionOrigin stack first, then replay it.
  • Asserts inside the app, results in the log — the harness (you, or a shell loop) only greps PASS/FAIL; the app is the oracle.
  • New commands are ~10 lines each — extend ReproScript.swift, don't work around it.

Soak scripts (§6): chain several trigger cycles at different positions with ordinary editing between them, assertcaret after each predictable step, and compare final rawLen across runs (byte-identical = deterministic). A soak green across 4–5 cycles is far stronger than one clean repro — it catches bugs needing armed state (round 6's queued fixup).

bypassdelete Sizemore,
assertcaret Strang
backspace 2
type xy
bypassdelete widely
assertcaret used in various
logsel

4. CGEvent driver (mouse-only paths, TCC willing)

For paths that must originate as HID events — real drag-select, drag-move, autoscroll — keyboard replay can't cover them. A ~70-line ui.swift (compile with swiftc) posting CGEvents does: bounds <substr> (window lookup via CGWindowListCopyWindowInfo), click x y, dragselect, dragmove (mousedown + ~400 ms hold before moving, or AppKit never arms the text drag), key, type.

Caveats (all hit in practice):

  • TCC decides per session. Background/agent sessions often can't post keyboard events (dropped silently) or use System Events. Test with **one click
    • log check**; if input doesn't land, fall back to §3 immediately — don't iterate on driver variations.
  • App windows are on an inactive Space until activated (kCGWindowIsOnscreen == false); osascript -e 'tell application "<path>.app" to activate' (Apple Events, a separate TCC bucket) may work where System Events is denied.
  • Re-activate before every interaction batch; focus is lost between shell calls.

5. Screencapture measurement

Visual judgments are measured, not eyeballed — when the task says "balance padding" or "align the icon", capture the window and measure pixels.

scripts/capture-window.sh <window-title-substring> out.png finds the window id (JXA → CGWindowListCopyWindowInfo) and runs screencapture -x -o -l<id>. Notes:

  • Capture by window id, reliable even when not frontmost.
  • Crop by the detected window bounds — the desktop wallpaper defeats screencapture's brightness-based auto-crop.
  • Measure padding/alignment from the PNG (e.g. a short Python/PIL pixel scan for the first/last colored row of a callout box). Report the pixel numbers, not an impression.
  • Window-server state can glitch (tiny windows, restoration) after many rapid launch/kill cycles: rm -rf ~/Library/"Saved Application State"/com.i7t5.edmund.savedState and relaunch.

6. The loop, end to end

  1. Verbose trace from the occurrence → find the first bad line, walk backwards, form a trigger hypothesis (§2).
  2. Reconstruct the document; script the hypothesized trigger (§3).
  3. No repro? Hypothesis wrong or fidelity too low — move down the ladder (§1), or instrument and wait for the next hit.
  4. Repro in hand? Freeze it (exact script + document), then let it falsify fix candidates — round 6's first "fix by reasoning" failed in the repro within a minute.
  5. Fix verified → soak (§3) → full swift test → keep the script + new diagnostics → update the relevant docs/*-investigation.md.

Meta-lesson from six rounds: time spent making the failure cheap to observe beats time spent reasoning about the fix. Every round that shipped on reasoning alone came back; the round that shipped on a deterministic repro named the actual mechanism.


scripts/ (in this skill dir)

Script Purpose Status
check-live-instance.sh Report running edmd, exit 2 if any; never kills logic verified; safe by construction
grep-trace.sh [date] Surface suspect patterns in today's log logic verified
capture-window.sh <needle> <out.png> Screenshot a window by id + report bounds verify on first use (JXA CGWindowList lookup not executed this session)
launch-debug.sh <file.md> [script.repro] Build + assemble EdmundDbg.app + direct-exec with flags verify on first use (assumes arm64 debug triple; guards user instance)

All four pass bash -n. The two "verify on first use" scripts depend on live system state (window server, build layout) that couldn't be exercised while authoring; read the header comment before first run.


When NOT to use this skill

  • Deciding which mechanism a symptom implies → edmund-debugging-playbook.
  • Running the full caret-integrity investigation → edmund-caret-integrity-campaign.
  • Stale-binary detection / bundle internals → edmund-build-and-env.
  • What counts as sufficient evidence to ship → edmund-validation-and-qa.
  • The AppKit theory behind the fixup/marked-text mechanisms → textkit2-appkit-reference.

Provenance and maintenance

Verified 2026-07-05 against docs/dev-guides/live-repro-guide.md, Sources/edmd/App/ReproScript.swift, and Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift.

grep -oiE '"(sleep|caret|type|backspace|bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u
grep -n 'debug.reproScript' Sources/edmd/App/ReproScript.swift
grep -rn 'traceSelectionOrigin\|LEN-MISMATCH\|shouldTrace' Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift Sources/EdmundCore/Diagnostics/Log.swift
grep -n 'healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift

Re-verify the scripts against docs/dev-guides/live-repro-guide.md §4 if the debug-bundle assembly recipe changes.

负责 Edmund 应用的发布与运维,涵盖版本升级、Git 打标签、CI/CD 构建流程及 DMG 打包。处理 Sparkle 更新签名、Gatekeeper 拦截、崩溃日志分析及应用启动故障,确保发布合规与安全。
执行版本升级或调试 Edmund 发布流程 操作已发布的 Edmund 应用程序
.agents/skills/edmund-release-and-operate/SKILL.md
npx skills add I7T5/Edmund --skill edmund-release-and-operate -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-release-and-operate",
    "description": "Load when cutting or debugging an Edmund release, or operating the shipped app. Triggers: version bump (Info.plist CFBundleShortVersionString \/ CFBundleVersion), tagging vX.Y.Z, CHANGELOG.md release sections, release.yml \/ release.sh \/ build-app.sh, appcast.xml or Sparkle update failures (\"improperly signed\", update never offered), sign_update \/ EdDSA keys \/ SPARKLE_ED_PRIVATE_KEY \/ RELEASE_TOKEN, create-dmg or DMG naming problems, Gatekeeper \"damaged\" reports, launching the built app, reading ~\/.edmund\/logs, crash reports (edmd-*.ips), or roadmap\/priority questions."
}

Edmund — release & operate

Date-stamped 2026-07-05. Verified against .github/workflows/release.yml, scripts/release.sh, scripts/build-app.sh, scripts/changelog-to-html.py, appcast.xml, Info.plist, CHANGELOG.md, docs/ARCHITECTURE.md §8/§13, and the Settings/CrashReporter sources. Where a doc and a script disagree, the script is the truth; disagreements are flagged inline.

House rule: releases happen only when the maintainer explicitly asks. Never tag, push, create a release, or merge on your own initiative — see edmund-change-control. Everything in §1–§4 below is a runbook for when the maintainer says "cut a release", not a standing instruction.

When NOT to use this skill

You actually need Go to
Build/test commands, stale-build cures, launch mechanics in depth edmund-build-and-env
Editing invariants, render pipeline, TextKit 2 rules edmund-architecture-contract, textkit2-appkit-reference
Debugging a bug in the app itself edmund-debugging-playbook, edmund-live-repro-and-diagnostics
Past incidents and why the sharp edges below exist edmund-failure-archaeology
Debug flags / launch arguments edmund-config-and-flags
Branch/commit/PR etiquette, what needs maintainer sign-off edmund-change-control
Pre-merge QA method edmund-validation-and-qa
README/website/positioning copy edmund-docs-and-writing, edmund-external-positioning

1. Release flow — CI path (the normal one)

Ship via a tag; CI does the rest. In order:

  1. Bump versions in Info.plist — both keys:
    • CFBundleShortVersionString — marketing version, e.g. 0.1.3
    • CFBundleVersion — build number, monotonic integer (0.1.3 = 4)
  2. Add a ## [x.y.z] section to CHANGELOG.md — format is load-bearing, see §2. The version MUST match Info.plist exactly.
  3. Merge to main and push (via the normal PR flow).
  4. Tag and push the tag:
    git tag vX.Y.Z && git push origin vX.Y.Z
    
  5. CI (.github/workflows/release.yml, trigger push: tags: 'v*', runner macos-14, job release / "Build & publish") runs the steps below.
  6. Afterwards, verify per §4 post-flight.

release.yml step anatomy (actual step names)

Step What it does Sharp edge
actions/checkout@v5 fetch-depth: 0, token: ${{ secrets.RELEASE_TOKEN }} The PAT must be on this step — see §3.4
maxim-lobanov/setup-xcode@v1 latest-stable Xcode
Cache .build SPM cache keyed on Package.resolved
Build app bundle ./scripts/build-app.sh — release build, bundle assembly, Sparkle embed, bundle sealing §3.2
actions/setup-node@v4 pins Node 20 create-dmg 8.x needs Node ≥ 20
Install create-dmg npm install --global create-dmg (sindresorhus/create-dmg, not Homebrew's) §3.5
Create DMG reads VERSION from Info.plist, create-dmg build/Edmund.app build/ || true, renames "Edmund <v>.dmg"Edmund-<v>.dmg, fails loudly if no dmg §3.5
Sign archive (EdDSA) finds sign_update in .build, key on stdin via --ed-key-file -, exports ED_SIG and LENGTH §3.1
Create GitHub Release awk-extracts the CHANGELOG section → gh release create "v${VERSION}" build/Edmund-${VERSION}.dmg --title "Edmund ${VERSION}" --notes-file … --latest §2
Update appcast.xml builds the new <item> (HTML <description> via scripts/changelog-to-html.py), inserts it before </channel>, commits as github-actions[bot], git push origin HEAD:main §3.4

Local path (scripts/release.sh)

Mirrors CI: build-app.sh → create-dmg (+ rename) → EdDSA sign → update appcast.xml locallygh release create. Two differences:

  • Signing: with SPARKLE_ED_PRIVATE_KEY in the env it uses the stdin path (CI-style); otherwise sign_update pulls the key from the login keychain (put there by Sparkle's generate_keys) with no flag at all.
  • The appcast commit/push is left to you. The script ends with the exact commands: git add appcast.xml && git commit -m 'Release <v>' && git push.

Prereqs for the local path: gh auth status authenticated, npm create-dmg installed, swift build has run at least once (so sign_update exists under .build).

Stale doc: misc/how-to-release.md still says the artifact is a zip ("signs the zip", "Zip it to build/Edmund-1.0.zip"). That predates the DMG switch. The truth is DMG throughout — per release.yml, release.sh, and ARCHITECTURE §13. Trust the scripts, and fix that doc when touching it.


2. CHANGELOG format contract (release notes are machine-extracted)

Both release.yml and release.sh extract the GitHub Release body with:

awk "BEGIN{p=0} /^## \[${VERSION}\]/{p=1;next} p && /^## \[/{exit} p{print}" CHANGELOG.md

So the section header must start at column 0 as ## [x.y.z] — literally ## [0.1.3] — 2026-07-04 in house style (em dash + ISO date after the bracket is fine; the match only requires the ^## \[x.y.z\] prefix). Extraction runs until the next ^## [ line. If nothing matches, the release body falls back to "See CHANGELOG for details." — a silent-ish failure, so get the header right. (Version dots are unescaped in the regex; harmless in practice, don't rely on it.)

The Sparkle update-dialog notes come from the same section via scripts/changelog-to-html.py <version>, a deliberately tiny converter that only understands Keep-a-Changelog shapes:

  • ### Added / ### Changed / ### Fixed<h3>use ###, not ##. The 0.1.2 appcast item literally shows <p>## Changed</p> because the section used ## subheads at release time; the converter passed them through as paragraphs.
  • - / * bullets → <ul><li>; indented continuation lines fold into the previous bullet.
  • `code` and **bold** are converted. Markdown links are NOT[docs](docs/foo.md) appears literally in the update dialog (see the 0.1.2 item). Keep appcast-facing notes link-free or accept the raw brackets.
  • Blank lines and --- are skipped; anything else becomes a <p>.
  • Missing section → empty output → the <description> is simply omitted.

House format (verified from CHANGELOG.md): Keep a Changelog 1.1.0 + SemVer, newest first, sections separated by ---.


3. The sharp edges (each one killed or nearly killed a real release)

3.1 sign_update -s is FATAL — key goes on stdin

Sparkle deprecated -s <key>; for newly generated keys it prints a deprecation warning and exits 1 ("no longer supported"). This killed the first 0.1.0 release. The only correct invocation with a key-in-hand:

echo "$SPARKLE_ED_PRIVATE_KEY" | sign_update --ed-key-file - <dmg>

Both release.yml and release.sh do exactly this. Never "simplify" it back to -s. Output format: sparkle:edSignature="<sig>" length="<n>" — the scripts grep those two attributes out for the appcast item.

3.2 Bundle sealing (the "improperly signed" update failure)

At install time Sparkle re-validates the update's Apple code signature (SUUpdateValidator), independent of the EdDSA signature. A bundle that is code-signed but not sealed (no _CodeSignature/CodeResources) fails that check and every update dies with "The update is improperly signed and could not be validated" — which is exactly what broke the v0.1.0 → 0.1.1 update when the old script signed only the bare binary.

build-app.sh therefore signs inside-out and in a very deliberate order:

  1. codesign --force --deep --sign - Sparkle.framework (nested XPC helpers must be signed before macOS will launch them),
  2. codesign --force --deep --sign - --identifier "com.i7t5.edmd" on the whole .app while its root holds only Contents/ — codesign refuses to seal a bundle with extra items at the root,
  3. copy the SwiftMath resource bundle to the .app root after sealing (its generated Bundle.module looks at Bundle.main.bundleURL; without it the app crashes on the first LaTeX render).

Consequence: codesign --verify (CLI) and --strict will complain about that one unsealed root item. That is expected and fine — Sparkle's actual check is non-strict (SecStaticCodeCheckValidityWithErrors with kSecCSCheckAllArchitectures) and tolerates it; verified end-to-end against that API. Do not "fix" the verify warning by moving the SwiftMath bundle or re-signing after the copy.

3.3 Keypair discipline

One EdDSA keypair, three places, all of which must agree:

Place Used by
Info.plist SUPublicEDKey (0XdLbbuO…) Every shipped app, to verify updates
Maintainer's login keychain release.sh local signing (no flag)
GitHub secret SPARKLE_ED_PRIVATE_KEY CI signing

If the signing key and SUPublicEDKey diverge, everything looks fine — the DMG signs without error — but every user's update fails signature verification. Sanity check when in doubt: sign_update --verify <dmg> <sig> against the Info.plist public key.

3.4 Appcast push to protected main — RELEASE_TOKEN

The workflow's last step commits appcast.xml and pushes to main, which requires the test status check. The default GITHUB_TOKEN / github-actions[bot] is not an admin, so that push is rejected with GH006 … protected branch hook declined. Branch protection has enforce_admins: false, so an admin's push bypasses the check — hence the fine-grained admin PAT in the RELEASE_TOKEN secret (Contents: read/write), set as the token: on the checkout step, not on the push. That placement matters: actions/checkout persists an http.<host>.extraheader credential that overrides inline-URL credentials, so rewriting the push URL would keep pushing with the bot token anyway.

RELEASE_TOKEN expires 2027-06-27. Rotate it before then or every release fails at the appcast push while the GitHub Release itself succeeds (a confusing half-shipped state — see §4 post-flight).

3.5 create-dmg quirks

  • It's the npm package create-dmg (sindresorhus), installed via npm install --global create-dmg. Homebrew's create-dmg is a different tool with an incompatible CLI. Requires Node ≥ 20 (CI pins it).
  • It exits 2 when it can't Developer-ID-sign/notarize the image (Edmund ships ad-hoc) but still produces the .dmg. Both scripts run it with || true and then verify the file exists, failing loudly only if no dmg was produced. Don't remove the || true; don't trust the exit code.
  • Output is named "Edmund <version>.dmg" — with a space. Both scripts rename to Edmund-<version>.dmg (hyphen), which is the name the appcast enclosure URL expects. If a rename is skipped, the release asset URL 404s for every updater.

4. Pre-flight and post-flight

Pre-flight (distilled from misc/before-you-release.md — read it too)

  • swift test green on main, not just the branch; git status clean.
  • No debug flags / launch args left on (repro drivers, verbose tracing — see edmund-config-and-flags, ARCHITECTURE §8).
  • Visual sanity: build and screencapture the editor in light and dark mode; click through everything the CHANGELOG claims ("fixed X" → actually reproduce X and confirm).
  • CHANGELOG.md has ## [x.y.z] — YYYY-MM-DD for this release and the version matches Info.plist (CFBundleShortVersionString); ### subheads, not ## (§2).
  • CFBundleVersion bumped (monotonic int).
  • RELEASE_TOKEN not expired (2027-06-27).
  • Local path only: gh auth status ok; keychain key matches SUPublicEDKey (§3.3).

Post-flight

  • GitHub Release vX.Y.Z exists with the right notes and the Edmund-<v>.dmg asset (hyphenated name).
  • appcast.xml on main got the new <item> — with <description>, correct sparkle:version (= CFBundleVersion) and enclosure URL.
  • Nothing to do for user prompts: Sparkle checks roughly daily; existing users see the update within ~24 h. Don't panic if it isn't instant.

If the Release exists but the appcast commit is missing, the release half-shipped (usually §3.4). Fix the token, then add the <item> manually or re-run the job.


5. Gatekeeper story (why users see "damaged")

Edmund is ad-hoc signed, not notarized (no $99/yr Developer ID). First launch of a downloaded copy trips Gatekeeper with the "app is damaged" dialog. This is expected; the app is fine. The README documents both workarounds (verified, README ~line 53):

  • xattr -dr com.apple.quarantine /Applications/Edmund.app, or
  • right-click → Open.

Known upgrade path (open/candidate, not scheduled): Developer ID certificate

  • notarization would remove the prompt entirely and also clean up the non-strict-sealing compromise in §3.2. Don't promise it in user-facing text.

6. Operating the app

Launching

open Edmund.app foregrounds a running instance instead of relaunching — you'll stare at old code. And never pkill -x edmd blindly: the maintainer's own Edmund session may be running (the Mach-O is edmd for both). Check first (pgrep -x edmd), kill only PIDs you started, or launch the binary directly: build/Edmund.app/Contents/MacOS/edmd file.md &. Full launch / stale-build / screencapture mechanics: edmund-build-and-env.

Logs — ~/.edmund/logs/edmund-YYYY-MM-DD.log

  • One file per day, human-readable lines tagged LEVEL [category] (categories: app, document, io, render, compose, selection, lazy, callout, edit — grep by concern).
  • Controlled by Settings ▸ Advanced ▸ Diagnostics ("Save diagnostic logs"). The toggle defaults OFF (AppSettings.diagnosticLogging defaults false) — i.e. opt-in in the shipped app, despite Log.swift's header comment calling it "always-on (opt-out)"; the code is the truth. (The UserDefaults keys are named settings.general.* for legacy reasons; the UI lives in Advanced.)
  • Retention picker ("Clear logs after:") next to the toggle; a separate "Verbose editor tracing" opt-in gates keystroke-level trace lines — leave off except during repros (edmund-live-repro-and-diagnostics).
  • Release builds write info and up; DEBUG builds also write debug.
  • Logs may contain document text; they never leave the machine.

Crash reports — ~/Library/Logs/DiagnosticReports/edmd-*.ips

  • macOS names crash reports after the Mach-O executable: look for edmd-<timestamp>.ips, not "Edmund-…".
  • Uploading is opt-in and currently INERT. The Settings toggle is commented out in Sources/edmd/Settings/AdvancedSettingsView.swift ("dormant until the receiving server exists"), and CrashReporter.reportingEndpoint is a placeholder (https://REPLACE-ME.invalid/crash). Nothing is ever sent in shipped builds. Don't tell users crash reporting exists; don't uncomment the toggle without a real server. Code: Sources/EdmundCore/Diagnostics/CrashReporter.swift.
  • Reading works only because Edmund is not sandboxed; adopting App Sandbox would force a MetricKit rewrite (noted in CrashReporter's header).
  • Triage of a user's .ips: it's JSON — a one-line metadata header, then the report body. Look at exception (type/signal), faultingThread, and walk that thread's frames for images named edmd or Sparkle. Ad-hoc builds ship no dSYM, so expect addresses rather than symbol names for app frames; correlate with ~/.edmund/logs from the same timestamp instead. .ips files embed the user's home path and device model — treat as mildly personal data.

Update mechanics (user side)

  • SUFeedURL = https://raw.githubusercontent.com/I7T5/Edmund/main/appcast.xml — the raw-GitHub URL of the checked-in appcast; committing to main is publishing.
  • SUEnableAutomaticChecks is true; no custom interval is set, so Sparkle uses its default ~24 h cadence (plus a check on launch).
  • Sparkle downloads the DMG enclosure, verifies EdDSA against SUPublicEDKey, mounts the DMG, re-validates the Apple code signature (§3.2), installs.

7. Versioning & appcast conventions

Thing Convention Current (2026-07-05)
Git tag vX.Y.Z v0.1.3 pending its tag; last released 0.1.2
CFBundleShortVersionString SemVer marketing version 0.1.3
CFBundleVersion monotonic integer, +1 per release 4
CHANGELOG Keep a Changelog 1.1.0, ## [x.y.z] — YYYY-MM-DD, ### subheads, --- separators

appcast.xml (checked into repo root): RSS 2.0 with the sparkle: namespace. One <channel> (title/link/description/language) containing one <item> per release. Items are inserted before </channel>, so the file reads oldest → newest; Sparkle doesn't care about order — it picks by version. Per item:

<item>
    <title>Edmund 0.1.2</title>
    <pubDate>Fri, 03 Jul 2026 19:03:59 +0000</pubDate>
    <description><![CDATA[ …HTML from changelog-to-html.py… ]]></description>
    <enclosure url="https://github.com/I7T5/Edmund/releases/download/v0.1.2/Edmund-0.1.2.dmg"
               sparkle:version="3"                <!-- CFBundleVersion -->
               sparkle:shortVersionString="0.1.2" <!-- marketing version -->
               sparkle:edSignature="…"
               length="7608991"
               type="application/x-apple-diskimage"/>
</item>

<description> is optional (omitted when the CHANGELOG section is missing).


8. Roadmap context (for release-content judgment)

  • Edmund is in beta (0.1.x line, first public release 0.1.0 on 2026-06-27). Small, frequent releases.
  • v0.2.0 goal: "Polished editing experience" (misc/backlog.md § Now).
  • Priority ordering: Marketing = Bugs >= UI/UX > Features — when deciding what makes a release, bug fixes and polish beat new features.
  • Long-range plan (v1.0 = onboarding + full GFM + extensions groundwork): docs/ROADMAP.md; working backlog with per-bug detail: misc/backlog.md.

Provenance and maintenance

Written 2026-07-05 from direct reads of: .github/workflows/release.yml, scripts/release.sh, scripts/build-app.sh, scripts/changelog-to-html.py, appcast.xml, CHANGELOG.md, Info.plist, README.md, docs/ARCHITECTURE.md (§8, §13), misc/how-to-release.md, misc/before-you-release.md, docs/ROADMAP.md, misc/backlog.md, Sources/EdmundCore/Diagnostics/CrashReporter.swift, Sources/EdmundCore/Diagnostics/Log.swift, Sources/edmd/Settings/AdvancedSettingsView.swift, Sources/edmd/Settings/AppSettings.swift.

Known stale docs at time of writing: misc/how-to-release.md (zip vs DMG, §1); Log.swift header ("always-on (opt-out)" vs the actual default-off toggle, §6). Minor oddity, deliberate: build-app.sh signs with --identifier "com.i7t5.edmd" while the bundle id is com.i7t5.edmund.

Re-verify when any of these change: release.yml step names or secrets, build-app.sh signing order, the CHANGELOG header format (the awk regex in two places must match it), SUFeedURL, RELEASE_TOKEN rotation (hard deadline 2027-06-27), notarization status, or the crash-report server going live (which un-inerts §6's crash uploading and this skill's wording).

列出 Edmund Markdown 编辑器的前沿研究课题,聚焦突破 TextKit 2 在超大文档视图稳定性和光标完整性方面的技术瓶颈。用于评估新方向价值及规划具体验证步骤。
选择下一个重大研发问题 评估激进创意是否值得启动 探讨如何超越当前技术极限
.agents/skills/edmund-research-frontier/SKILL.md
npx skills add I7T5/Edmund --skill edmund-research-frontier -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-research-frontier",
    "description": "Open problems where the Edmund Markdown editor could advance the state of the art — product-first, everything labeled candidate\/open, nothing proven. Load when picking the next big problem, evaluating whether an ambitious idea is worth starting, or answering \"what would move this project beyond state of the art\". Each frontier: why current SOTA fails, Edmund's specific asset, the first three concrete steps IN THIS REPO, and a falsifiable \"you have a result when…\" milestone. Not for running an accepted investigation (edmund-caret-integrity-campaign), the method of proof (edmund-research-methodology), or shipping the change (edmund-change-control)."
}

Edmund research frontier

Where Edmund could go past the state of the art. Ambition is product-first: "the CotEditor of Markdown editors" (README). Advanced TextKit 2 techniques are means, not ends — a technique is worth pursuing when it makes the product better, and it becomes publishable as a side effect.

Everything here is candidate / open. Nothing is proven. Each item routes its proof through edmund-research-methodology (hypothesis predicts numbers) and its changes through edmund-change-control. Verified 2026-07-05 against the repo; assets cited are real, outcomes are not.


Frontier 1 — Viewport-stable TextKit 2 at scale (>100k UTF-16)

Why SOTA fails: TK2 lays out only near the viewport; off-screen fragment heights are estimates corrected as layout reaches them. This makes the scroller jump and scroll-to-target miss in every TK2 app, including TextEdit — a widely documented limitation. Above fullLayoutMaxLength (100k) Edmund enters this regime.

Edmund's asset: the mitigations already in TextView/scheduleFullLayoutSettle, preservingViewportAnchor, repairContentAboveOrigin, centerViewportOnCaret re-measure, and the diff-based undo restore that avoids resetting fragments to estimates; plus the ScrollStabilityTests / HeightStabilityTests harnesses. (Note: repairContentAboveOrigin is mitigated-unconfirmed live per docs/investigations/viewport-glitch-investigation.md — a real result here would also retire that honest gap.)

First three steps in this repo:

  1. Build a large-doc fixture (makeLargeMarkdown in TestHelpers.swift) >100k and a scripted scroll-accuracy metric (ReproScript caret + a logsel-style position dump, or extend PerfHarnessTests).
  2. Quantify the estimate-error distribution: for N scroll-to-target operations, record predicted vs actual landing pixel offset.
  3. Prototype persistent per-fragment height caching across the settle (or across sessions) and re-measure the same distribution.

You have a result when: scroll-to-target lands within a stated pixel budget on a 1 MB document, measured by script, with zero repairing content above origin events across a scroll soak — reproducibly.


Frontier 2 — Caret integrity by construction

Why SOTA fails: every NSTextView consumer depends on didChangeText pairing that AppKit itself violates (the drag-move bypass). The delete-drift class is the symptom of building sync on a callback contract AppKit doesn't keep.

Edmund's asset: the heal machinery, the pendingEdit model, six documented rounds of mechanism knowledge, and the ReproScript + soak methodology. The campaign skill runs individual rounds reactively; this frontier is the structural endgame — eliminate the class.

First three steps:

  1. Inventory every storage-mutation entry point (grep replaceCharacters, setAttributes, the edit-flow paths) and tabulate which currently rely on a callback firing.
  2. Design a sync layer keyed on a storage-version counter that reconciles rawSource regardless of which callbacks fired (not a new guard per path).
  3. Falsify it against all historical .repro scripts plus a new randomized bypass-fuzzer script.

You have a result when: all historical .repro scripts and a randomized bypass soak stay green with the callback-pairing assumption deleted from the code. Candidate, big — likely a multi-PR redesign; do not start without the methodology skill's evidence bar in front of you.


Frontier 3 — The 10 MB class (performance headroom)

Why it matters: README claims "~1–2 MB files"; native-with-no-Electron is the differentiator, so headroom is a product claim, not vanity.

Edmund's asset: viewport-based lazy styling, the idle drain, incremental reparse (pendingEdit window), PerfHarnessTests.

First three steps:

  1. Extend PerfHarnessTests with 5/10 MB fixtures (makeLargeMarkdown).
  2. Profile the block-parse and restyle hot paths (Log.measure single-line durations are already in place).
  3. Set explicit latency budgets for open, first-paint, and per-keystroke restyle at 10 MB.

You have a result when: open + steady-state typing latency on a 10 MB file meets a stated budget, measured by the harness (not hand-timed).


Frontier 4 — A native extensions API

Why SOTA fails: Obsidian/VS Code plugin ecosystems are Electron; there is no strong precedent for a native, safe, fast extension surface for live-preview Markdown on macOS. ROADMAP v1.0.0 lists "Extensions API, documentations, primitive marketplace" and flags Advanced Syntax Highlighting / Advanced Math as "official extension" candidates.

Edmund's asset: the custom-parser seam (Parsing/SyntaxHighlighter+CustomParsers.swift), the BlockKind/styleBlock architecture, and already-modular opt-in syntax (math, Obsidian syntax).

First three steps:

  1. Catalog which existing features could be re-implemented as extensions (dogfood: callouts? highlight? wikilinks?) — this defines the real extension points.
  2. Define the minimal seam: block parser? inline parser? theme hook? Draw the line at what the custom-parser architecture already supports.
  3. Spike one official extension behind a flag and measure restyle cost vs the built-in.

You have a result when: one built-in syntax feature runs as an extension with no measurable restyle regression against the built-in baseline.


Frontier 5 — Accessibility / RTL / localization as a differentiator

Why it matters: native apps can excel where Electron editors are weak; ROADMAP v1.x lists Localization, RTL, Accessibility. Locale-aware content width already ships as precedent that the pipeline can be locale-sensitive.

Edmund's asset: the attribute-only pipeline (structure is in the string, not in inserted characters), the existing locale-aware content-width path.

First three steps:

  1. VoiceOver audit of EditorTextView's custom drawing — does the accessibility tree expose headings/lists/callouts, given they're drawn as decorations?
  2. Test RTL behavior of the attribute-only styling on a right-to-left document.
  3. Scriptable a11y check (structure read-out) as a regression guard.

You have a result when: a scripted VoiceOver audit reads document structure (headings, list items, callouts) correctly on a mixed document.


How to start one

  1. Predict numbers first (edmund-research-methodology §2) — every milestone above is a number, not a vibe.
  2. Instrument to make the current failure/limit cheap to measure.
  3. Prototype behind a flag; measure predicted vs observed.
  4. Route changes through edmund-change-control (branch, tests, no auto-push).
  5. When the first milestone lands, the item graduates to misc/backlog.md or docs/ROADMAP.md and stops being a frontier.

What NOT to start (no current asset)

  • iOS / iPadOS port — explicitly TBD in ROADMAP; no shared UI layer today.
  • Collaborative / real-time editing — zero repo support (no CRDT, no sync, single NSDocument model). Would be a new product, not a frontier of this one.
  • Anything that requires breaking an invariant (storage == rawSource; TextKit 2 only) to work — that's not a frontier, it's a rewrite (edmund-architecture-contract).

When NOT to use this skill

  • Running an accepted investigation (a known bug) → edmund-caret-integrity-campaign / edmund-debugging-playbook.
  • The method of turning a hunch into proof → edmund-research-methodology.
  • Whether a public claim is allowed yet → edmund-external-positioning.
  • Shipping the change → edmund-change-control.

Provenance and maintenance

Verified 2026-07-05. Assets exist; outcomes are unproven by definition — never quote a milestone here as achieved.

grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
grep -rn 'repairContentAboveOrigin\|preservingViewportAnchor' Sources/EdmundCore/TextView/
ls Sources/EdmundCore/Parsing/SyntaxHighlighter+CustomParsers.swift
grep -n 'Extensions API\|RTL\|Localization\|iPadOS' docs/ROADMAP.md
grep -rn 'func makeLargeMarkdown' Tests/EdmundTests/TestHelpers.swift

When an item's milestone lands, move it to ROADMAP/backlog and delete it here — a frontier list that keeps solved problems is lying.

指导Edmund编辑器测试证据标准,明确Swift Testing框架下不同变更类型所需的验证强度(如头测、窗口化、重现脚本及视觉QA),确保修复有效。
编写或调整测试用例 判断修复是否需要额外证明 解释测试套件结构 添加金标准回归检查
.agents/skills/edmund-validation-and-qa/SKILL.md
npx skills add I7T5/Edmund --skill edmund-validation-and-qa -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-validation-and-qa",
    "description": "What counts as EVIDENCE in the Edmund Markdown editor, and how to add tests. Load when writing or adjusting tests, deciding what proof a fix needs before it can ship, judging whether a change is actually \"verified\", interpreting the test suite, or adding a golden\/regression check. Covers the evidence hierarchy (headless is necessary but not sufficient for live-input bugs), the test-suite map, the Swift Testing helpers, how to add a test, the golden inventory, visual QA, and performance evidence. Not the gating rules themselves (edmund-change-control) or how to run the live repro (edmund-live-repro-and-diagnostics)."
}

Edmund validation & QA

The discipline of proof. The central lesson: a green headless test is necessary but not sufficient for the bug classes that cost the most time here. Know which evidence each change actually requires.

Framework: Swift Testing (import Testing, @Test, #expect, #require) — NOT XCTest. Verified 2026-07-05: 810 @Test cases across Tests/EdmundTests/.


1. The evidence hierarchy

Ordered weakest → strongest. The rule at the bottom names which class each change type requires.

Class What it proves Blind spot
(a) Headless unit test (makeEditor()) model/parse/style/undo logic Cannot exercise deferred AppKit machinery — runs it synchronously
(b) Windowed unit test (real NSWindow + NSScrollView, real deleteBackward) + layout, viewport, first responder still not real event-loop pacing / IME / drag
(c) Frozen live ReproScript repro (exact script + document) the live input/timing mechanism needs a debug build + ~1 min/run
(d) Soak script green across 4–5 cycles, byte-identical final rawLen armed-state + determinism slow
(e) Screencapture pixel measurement anything that DRAWS manual

The trap that defines this skill: the delete-drift round-6 regression test passes with AND without the fix — the harness runs the queued selection fixup synchronously, so headless can't see the bug. For caret/IME/drag/viewport-timing bugs, class (a) is not evidence of a fix; you need (c)+(d).

Required evidence by change type (gates enforced in edmund-change-control):

Change Requires
Pure logic (parse/style/model) (a)
Viewport/lazy-layout (b), often (e)
Anything that draws (a where testable) + (e) in light AND dark
Edit-pipeline / selection / IME / undo (a) to lock the headless contract + (c) frozen repro + (d) soak
Release see edmund-release-and-operate

2. Test-suite anatomy

Run: swift test (full suite; ARCHITECTURE cites ~750+ tests ≈10s — 810 @Test cases as of 2026-07-05). One suite: swift test --filter <Suite>. swift test also runs automatically as a Stop hook (.Codex/settings.json) at the end of any turn touching code, so failures surface before you commit.

Map of Tests/EdmundTests/ (what the files actually cover):

  • Parsing: BlockParserTests, SyntaxHighlighterTests, IncrementalParseFuzzTests, PendingEditTests, EscapeRenderingTests, HTMLTagRenderingTests, EmojiRenderingTests, LineEndingTests.
  • Rendering / styling: BlockStylingTests, CalloutRenderingTests, CalloutTests, CommentRenderingTests, NestedBlockStylingTests, InlineStylingTests, MathRenderingTests, ImageRenderingTests, CodeHighlighterTests, FootnoteTests, WikiLinkTests, TableAlignmentTests, RenderingRegressionTests.
  • Editor behavior: EditorIndentationTests, ListContinuationTests, BlockquoteContinuationTests, BlockquoteDeletionTests, FormattingTests, ActiveBulletMarkerTests, NewListItemAlignmentTests.
  • Edit-pipeline integrity (the costly class): BypassedEditSyncTests, MarkedTextDesyncTests, WrappedParagraphCaretTests, EditorDiagnosticsTests, UnmatchedDebugTests, InternationalInputTests.
  • Viewport / layout / undo: LazyRenderingTests, ScrollStabilityTests, HeightStabilityTests, TypewriterCenteringTests, UndoRedoViewportTests, EditorUndoTests, RecomposeTests, RecomposeEquivalenceTests.
  • Export / read mode: HTMLRendererTests, DocumentHTMLTests, ReadModeWebViewTests, HTMLThemeTests, EditorThemeTests, ViewModeTests, ContentWidthTests.
  • Infra / harness: LogTests, CrashReporterTests, PerfHarnessTests, StatusBarPrefsTests, FileIntegrationTests, EditorDocumentTests, TestHelpers.swift. _RenderDump.swift / _RenderEdit.swift are local dev tools (gitignored intent) that dump Read-mode HTML / edit output for tmp/sample.md — not part of the assertion suite.

Two invariant-guarding patterns worth copying:

  • Recompose equivalence (RecomposeEquivalenceTests, helper assertMatchesFullRecomposeOracle): an incremental restyle must produce the same result as a full recompose. This is the safety net for the lazy / incremental styling paths.
  • Incremental-parse fuzz (IncrementalParseFuzzTests): random edits must keep the parser's window-reparse consistent with a full reparse.

3. Test helpers (TestHelpers.swift)

@MainActor helpers you build on (verified exports):

Helper Use
makeEditor() an EditorTextView on the real TK2 chain (mirrors Document.makeWindowControllers)
ensureFullLayout(...) force layout so geometry is real, not estimated
type(...), paste(...), pressEnter(), pressBackspace() drive edits
activateBlock(...) move the caret / active block
displayText(...), attrs(...), font(...), fgColor(...) inspect styled output
isHidden / isInvisible / isDimmed assert delimiter hiding
blockDecoration(...), textBlockDifference(...) inspect decorations / catch TK1-reverting table attrs
expectedFullComposition(...), drainAllStyling(), assertMatchesFullRecomposeOracle(...) equivalence-oracle checks
makeLargeMarkdown(...), sentence(...) build big fixtures for perf/viewport

styleBlock(_:cursorPosition:) is a method on EditorTextView (Rendering/EditorTextView+Rendering.swift), called from tests — it renders one block to an attributed string.


4. How to add a test

Skeleton (Swift Testing, @MainActor because the editor is main-thread):

import Testing
import AppKit
@testable import EdmundCore

@MainActor
@Test func deletingAtCalloutBottomKeepsInvariant() {
    let editor = makeEditor()
    editor.loadRawSource("> [!note]\n> body\n")
    drainAllStyling()
    // ... drive the edit ...
    #expect(editor.rawSource == editor.string)   // storage == rawSource invariant
}

Rules:

  1. Every bug fix ships with a test even if it can't discriminate a live-only mechanism — it still locks the headless contract so a future refactor can't silently re-break the model half. For the live half, keep the frozen .repro script alongside (edmund-live-repro-and-diagnostics).
  2. Assert the invariant where you can (rawSource == string), not just the surface symptom — that's what BypassedEditSyncTests / MarkedTextDesyncTests do.
  3. Name the test for the behavior/bug, put edit-pipeline repros next to their siblings (the *Desync* / *Bypassed* / *CaretTests families).
  4. New drawing behavior additionally needs a screencapture check (§6).

5. Golden / certified inventory

  • RenderingRegressionTests + RecomposeEquivalenceTests are the closest thing to golden checks — they pin styled output and incremental-vs-full equivalence.
  • test-files/*.md (callout.md, decorations.md, math.md, menu.md, test.md) are the user's manual test corpus — hand-testing fodder. Do not rewrite them to fit an automated test; build your own fixtures (helpers in §3, or a scratch dir).
  • misc/bug-repros/ holds field evidence (.mov screen recordings + .log traces) for open bugs — reference these when reproducing, don't delete them.

6. Visual QA

Anything that draws is verified by screencapture pixel measurement, in light AND dark mode (per misc/before-you-release.md), never by eyeballing headless layout. Method + scripts: edmund-live-repro-and-diagnostics §5. Headless layout tests (HeightStabilityTests, ScrollStabilityTests) check geometry numbers but cannot confirm the pixels are right.


7. Flakiness & CI

  • A fix/flaky-math-test branch exists in history — math rendering has shown timing flakiness; if a math test flakes, check that branch's approach before inventing a new one (verify: git log --oneline --all -- '*Math*').
  • CI: .github/workflows/ci.yml on macos-14, latest-stable Xcode, SPM cache keyed on Package.resolved, concurrency: cancel-in-progress (private-repo macOS minutes bill 10×). CI runs the same swift test.

8. Performance evidence

  • PerfHarnessTests measures the hot paths; makeLargeMarkdown builds big fixtures. Use it (don't hand-time) for any perf claim.
  • The README claim "handles ~1–2MB files" and fullLayoutMaxLength = 100_000 UTF-16 (EditorTextView.swift:80) mark the boundary between the full-layout regime (≤100k, geometry is real) and the estimate regime (>100k, viewport glitches possible). A perf/viewport claim must state which regime it was measured in.

When NOT to use this skill

  • The gate/branch/commit rules → edmund-change-control.
  • Running the live repro or measuring pixels → edmund-live-repro-and-diagnostics.
  • The caret-integrity investigation end to end → edmund-caret-integrity-campaign.
  • Why a mechanism works → textkit2-appkit-reference / edmund-architecture-contract.
  • Release verification → edmund-release-and-operate.

Provenance and maintenance

Verified 2026-07-05.

grep -rh '@Test' Tests/EdmundTests/*.swift | wc -l         # ~810 cases
grep -c 'import Testing' Tests/EdmundTests/TestHelpers.swift # confirms Swift Testing, not XCTest
grep -oE 'func [a-zA-Z]+' Tests/EdmundTests/TestHelpers.swift # helper inventory (§3)
ls Tests/EdmundTests/                                        # suite map (§2)
grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift

If a helper in §3 no longer greps it was renamed; update §3 and any test skeletons. Re-derive the suite map from ls if files are added/removed.

AppKit TextKit 2 在 Edmund 编辑器中的参考文档,解释核心对象模型、基于视口的布局机制及高度估算导致的视图跳动问题。提供源码级排查指南与缓解策略,适用于处理布局、滚动和文本渲染异常。
TextKit 2 布局异常或视图跳动 NSTextView 行为不符合预期 需要理解 NSTextLayoutManager 或视口布局机制
.agents/skills/textkit2-appkit-reference/SKILL.md
npx skills add I7T5/Edmund --skill textkit2-appkit-reference -g -y
SKILL.md
Frontmatter
{
    "name": "textkit2-appkit-reference",
    "description": "Domain-theory pack for the AppKit text system as it applies to the Edmund Markdown editor. Load when working on layout, selection, IME, undo, drag, or eventing behavior; when TextKit 2 or NSTextView does something surprising; or when terms like NSTextLayoutManager, layout fragment, marked text, queued selection fixup, responder chain, or sendEvent appear and you lack AppKit text-system background. Explains the mechanisms the invariants and gotchas are built on. Not the invariants themselves (edmund-architecture-contract), not a triage table (edmund-debugging-playbook), not the repro drivers (edmund-live-repro-and-diagnostics)."
}

TextKit 2 / AppKit reference (as it applies to Edmund)

The background a mid-level engineer or Sonnet-class model usually lacks. Each concept: brief theory, then where it bites in Edmund with a verified file pointer. This is not a textbook — it is only the parts that matter here.

Facts checked against source 2026-07-05. Items labeled (background) are general AppKit/TextKit behavior grounded in Apple's documentation, not directly grep-able in this repo.


1. The TextKit 2 object model (background + repo)

TextKit 2 replaced the TextKit 1 NSLayoutManager stack. The players:

  • NSTextContentStorage — owns the backing string + attributes (the model).
  • NSTextLayoutManager — lays text out (the TK2 analogue of the old layout manager).
  • NSTextLayoutFragment — one laid-out chunk (≈ a paragraph); has a real geometric frame only once laid out.
  • NSTextElement / NSTextParagraph — the model-side elements fragments render.

Viewport-based layout is the headline difference: TK2 lays out only the content near the visible viewport, not the whole document. That is what makes big documents fast — and it is the root of most viewport pain (§2).

Where it bites: Edmund subclasses the fragment as DecoratedTextLayoutFragment (EditorTextView+TextKit2.swift) to draw callout boxes, bars, and overlays.


2. Height ESTIMATES — the master cause of viewport glitches

A fragment that has not been laid out yet has an estimated height, not a real one; the total document height is the sum of real + estimated fragment heights. As layout reaches a fragment, its estimate is replaced by the true value and everything below shifts. Consequences: the scroller thumb jumps, and "scroll to offset Y" lands wrong because Y was computed from estimates. This is a widely documented TK2 limitation — even TextEdit shows it (background).

Where it bites / Edmund mitigations (verify names by grep; all in TextView/):

  • fullLayoutMaxLength = 100_000 (EditorTextView.swift:80): documents ≤ 100k UTF-16 units are kept fully laid out (no estimate regime) by a coalesced next-run-loop settle.
  • scheduleFullLayoutSettle / preservingViewportAnchor: the settle runs inside an anchor block so corrections never shift what is on screen.
  • repairContentAboveOrigin (+LazyStyling.swift, logs repairing content above origin): fixes the case where an edit near the top strands the first fragment at negative y (unreachable above the scroller top).
  • centerViewportOnCaret: re-measures after its first scroll and corrects the residual estimate error.

Rule: never trust an off-screen fragment's y-coordinate without laying out its span first. Deep write-up: docs/investigations/viewport-glitch-investigation.md.


3. The TextKit 1 fallback trap

An NSTextView can silently and permanently revert from TK2 to the legacy TK1 stack. Two known triggers: accessing NSTextView.layoutManager (the mere getter engages TK1), and storing NSTextBlock/NSTextTable attributes. Once reverted, TK2 APIs still exist but do nothing useful, and the whole editor misbehaves subtly.

Where it bites: Edmund ships a DEBUG tripwire — an observer on NSTextView.willSwitchToNSLayoutManagerNotification that asserts if the switch happens (EditorTextView.swift:273+, message "TextKit 1 fallback triggered"). Never add code that reads layoutManager or stores table attributes; draw tables as decorations instead (EditorTextView+TableRendering.swift).


4. Attribute-only mutation semantics

Edmund renders by writing attributes onto the storage, never by inserting or deleting characters (the storage == rawSource invariant). Two consequences from the text system:

  • setAttributes does not re-measure geometry. After a restyle that changes a block's height or indent, you must call invalidateLayout(for:) on its range or the fragment keeps a stale frame (empty bands / clipped lines). recomposeDirty and the idle drain already do this; new paths must too.
  • NSTextAttachment is only honored on the U+FFFC object-replacement character (background). rawSource never contains U+FFFC, so attachments can't be used — Edmund draws images/icons as overlays (§5) instead.

5. The custom drawing model (fragments, decorations, overlays)

DecoratedTextLayoutFragment draws two attribute families behind/over text:

  • .blockDecoration (paragraph-level): callout boxes, quote bars, table borders, thematic-break rules, code backgrounds. Fragments tile vertically, so a multi-line run renders as one continuous box. A box's bottomPad grows the last fragment's frame (TK2 omits trailing paragraph spacing from the fragment, so padding done otherwise would be dead space).
  • .fragmentOverlay (character-level): an image or a stroked vector path drawn at a glyph's laid-out position — rendered math, list bullets/checkboxes, callout header icon, the custom-title callout icon (path). The anchor glyph is hidden (≈0.01 pt font + clear color) and .kern reserves the drawing's advance width.

The image-on-wrapping-fragment wedge: drawing an image overlay on a multi-line (wrapping) fragment re-triggers a layout pass that collapses the fragment to one line. Drawing a shape/path does not. So the wrapping callout title's icon is a stroked CGPath (parsed by SVGPath from vendored Lucide geometry), never an image. Full saga: docs/investigations/archives/callout-title-wrap-investigation.md. This constraint holds for any new overlay that could share a line with wrapping text.

Hiding text = hiddenFont (≈0.01 pt) + clear foregroundColor. This is how delimiters (**, `, [!note]) vanish without changing the string.


6. The queued selection fixup (the round-6 delete-drift mechanism)

When you mutate an NSTextView's storage, AppKit queues a private step, -[NSTextLayoutManager _fixSelectionAfterChangeInCharacterRange:], that repairs the selection against the new character coordinates. Normally it fires promptly. But if an edit bypasses the normal close-out (see §7), the fixup stays queued and fires at the next endEditingeven an attribute-only restyle — where it maps the now-stale selection against post-edit coordinates and leaps the caret blocks away. It will move even a freshly set, valid caret.

Where it bites: this is delete-drift round 6. The heal must set the caret (from the pendingEdit hull) before the sync and re-assert it after (EditorTextView+EditFlow.swift). Recognize a variant by: a suspicious selection change arriving mid-recompose (up=Y in traces); traceSelectionOrigin will log the call stack of whoever moved it.

Critical for testing: a headless test harness runs this deferred fixup synchronously, so this bug class cannot reproduce in a unit test — the round-6 regression test passes with and without the fix. Only the live in-process repro discriminates (edmund-live-repro-and-diagnostics).


7. The AppKit edit pipeline contract (and where AppKit breaks it)

Normal edit: shouldChangeText(in:replacementString:) → the view calls replaceCharactersdidChangeText(). Edmund's didChangeText syncs rawSource from storage and restyles the edited block(s).

AppKit does NOT always send didChangeText. A drag-move of selected text whose drop lands on no valid target (e.g. released past the end of the document) deletes the dragged range via shouldChangeTextreplaceCharacters and never calls didChangeText — silently freezing rawSource/blocks, after which every edit drifts and autosave writes stale content (delete-drift round 4). Edmund heals this: shouldChangeText schedules a next-run-loop bypass check (scheduleBypassedEditSyncCheck, +EditFlow.swift); an unconsumed storage pendingEdit by then means the close-out never came, and the editor runs the sync itself (breadcrumb: healing storage edit that bypassed didChangeText).

Never build a sync path on the assumption that didChangeText follows every edit.

The authentic key route (background + repo): keyDown → interpretKeyEventsinsertText: / deleteBackward:. This is why the repro driver synthesizes real NSEvents and pushes them through window.sendEvent(_:) rather than calling insertText directly — shortcuts skip deleteBackward's selection machinery, which is exactly where round 6 lived.


8. IME / marked text lifecycle

While an input method is composing (e.g. CJK, accents), the view holds provisional "marked" text in storage; hasMarkedText() is true. During this window storage == rawSource is transiently false, and didChangeText defers syncing until the composition commits.

The cascade: any styling path that runs beginEditing/setAttributes/invalidateLayout mid-composition can strand the marked text in the input context. After that, didChangeText keeps bailing on its own guard and the invariant stays broken — so every later edit drifts the caret (the original delete-drift bug). Therefore every storage-touching styling path must guard !hasMarkedText() — including async ones scheduled before composition began (the caret-move restyle in +SelectionTracking). becomeFirstResponder resyncs from storage as a catch-all. Full write-up: docs/investigations/delete-drift-investigation.md.


9. Responder chain & nil-target actions (background + repo)

The responder chain is AppKit's search order for who handles an action. Menu items and toolbar buttons with a nil target send their action up the chain until something responds. Edmund's Format menu (FormatMenu.swift) is a declarative command table whose items use nil targets and route to the focused EditorTextView's @objc format… actions — the same wiring as undo/redo. The first responder is normally the focused EditorTextView.


10. NSWindow.sendEvent — the pre-toolbar event funnel

Every event a window receives passes through sendEvent(_:) before the toolbar acts. This matters because with NSToolbar.allowsUserCustomization = true, the toolbar claims any secondary (right/control) click over the toolbar — including a custom item view — for its own "Customize Toolbar…" menu, downstream of view-level handlers (menu, rightMouseDown, gesture recognizers all lose). Edmund's fix for the view-mode button: intercept in DocumentWindow.sendEvent(_:), pop the menu when the click is inside the button's bounds, and swallow it (return); other clicks fall through to super. (Caveat: true fullscreen moves the toolbar to a separate window, so this main-window hook wouldn't cover it.)


11. Drag sessions (background + repo)

  • Text drag-move arming: AppKit only starts a text drag after a mouse-down hold (~400 ms); a CGEvent driver must hold before moving or the drag never arms.
  • Drag-select autoscroll: dragging past the viewport edge autoscrolls.
  • Reveal at nearest end: a selection taller than the viewport must be revealed at its nearest end (Edmund's scrollRangeToVisible override) — always revealing the top fought the drag-select autoscroll and oscillated the viewport mid-drag.

12. swift-markdown walker model (brief)

Edmund parses with apple/swift-markdown (CommonMark/GFM) and walks the resulting Document with two back-ends: a SpanCollector-style walk that produces editor attributes, and HTMLRenderer that produces HTML for Read mode. One parser, two outputs — so Edit and Read can't drift. Custom (non-CommonMark) syntax — callouts, ==highlight==, wikilinks, comments, footnotes, math — is handled by SyntaxHighlighter+CustomParsers.swift.


When NOT to use this skill

  • The project's rules/invariants (what you must not do) → edmund-architecture-contract.
  • Which symptom means which mechanism → edmund-debugging-playbook.
  • Reproducing a live bug / reading traces → edmund-live-repro-and-diagnostics.
  • The history of how these mechanisms were discovered → edmund-failure-archaeology.
  • Running the caret-integrity campaign → edmund-caret-integrity-campaign.

Provenance and maintenance

Verified 2026-07-05. Re-verify the load-bearing identifiers:

grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
grep -n 'willSwitchToNSLayoutManager' Sources/EdmundCore/TextView/EditorTextView.swift
grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
grep -rn 'repairContentAboveOrigin\|preservingViewportAnchor' Sources/EdmundCore/TextView/
grep -rn 'blockDecoration\|fragmentOverlay\|hiddenFont' Sources/EdmundCore/
grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/

Items marked (background) are AppKit/TextKit behavioral facts documented by Apple and in docs/*-investigation.md, not directly observable by grep. If any Edmund mitigation name above no longer greps, it was renamed — update this file and docs/ARCHITECTURE.md §5/§8 together.

定义Edmund Markdown编辑器的架构契约,涵盖TextKit 2渲染、存储同步及撤销流程。用于指导重大代码变更、重构或新机制设计,确保遵守存储与源码一致等核心不变量,避免光标漂移等已知缺陷。
进行非 trivial 的代码修改前 询问系统为何如此构建时 提议新机制、子系统或重构时 涉及显示字符处理、NSTextAttachment、布局管理器或存储同步时
.claude/skills/edmund-architecture-contract/SKILL.md
npx skills add I7T5/Edmund --skill edmund-architecture-contract -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-architecture-contract",
    "description": "The load-bearing design contract of the Edmund Markdown editor. Load BEFORE any non-trivial code change in this repo; when asking \"why is it built this way\"; before proposing a new mechanism, subsystem, or refactor; whenever you are tempted to insert\/strip display characters, use NSTextAttachment, touch NSTextView.layoutManager, store NSTextBlock\/NSTextTable attributes, or add a new overlay\/decoration; and before designing anything that syncs storage, selection, undo, or the viewport. Covers the two hard invariants (storage == rawSource; TextKit 2 only), the render pipeline, edit\/undo flow, the TextKit 2 drawing model, the read-mode contract, and the known weak points. Not for build\/run\/release mechanics, debugging triage, or live-repro drivers — see \"When NOT to use this skill\"."
}

Edmund architecture contract

Edmund is a native macOS Markdown editor with live preview: AppKit + TextKit 2, SwiftPM, macOS 14+. Two targets (Package.swift):

Target Role
EdmundCore Library: parsing, rendering, EditorTextView, all tests. Most work happens here.
edmd Executable: NSDocument app shell, Settings (SwiftUI), menus. Note: edmd is the Mach-O binary name; the app is "Edmund".

Project ambition (maintainer, 2026-07-05): product-first — "the CotEditor of Markdown editors". Bias toward polish of the editing experience over feature count. The hardest live problem class to date is delete-drift (caret/selection integrity, 6 investigation rounds); the costliest failures were delete-drift and undo/redo viewport drift. Every rule below traces to one of those scars.

Ground truth this file distills: docs/ARCHITECTURE.md (repo root). Treat that doc as authoritative if the two ever disagree, and fix this skill.

Glossary (each term defined once)

  • rawSource — the document's Markdown text, the single source of truth (EditorTextView.rawSource).
  • storage — the NSTextStorage the text view displays (EditorTextStorage, Sources/EdmundCore/TextView/EditorTextStorage.swift).
  • Block — one logical Markdown block (paragraph, heading, list run, code fence, table, quote/callout run). Model: Sources/EdmundCore/Model/Block.swift.
  • Active block — the block under the caret; it renders its raw markdown (delimiters visible/editable) while all others render styled.
  • Recompose — restyling storage from blocks (Sources/EdmundCore/TextView/EditorTextView+Composition.swift).
  • Fragment — an NSTextLayoutFragment, TextKit 2's per-paragraph layout unit. Off-screen fragments have estimated heights until laid out.
  • Overlay — an image or stroked path drawn at a character's laid-out position by the custom fragment class (see §4), replacing what NSTextAttachment would do in TextKit 1.
  • Delete-drift — the bug class where rawSource/storage/selection desync and every later edit lands the caret in the wrong place. Chronicle: docs/investigations/delete-drift-investigation.md.
  • IME composition — an input method's provisional "marked text" (hasMarkedText()), present in storage before the user commits it.

1. The two non-negotiable invariants

Break either and the editor misbehaves in subtle, delayed ways. Every design review starts here.

Invariant 1 — storage == rawSource (attribute-only rendering)

The displayed text storage is always character-identical to rawSource. Rendering only ever adds/changes attributes. Delimiters (**, `, [!note], …) are hidden, never stripped: hiddenFont (0.01pt system font, Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift:55) plus a clear foregroundColor makes them invisible without touching the string.

Rationale. Identity mapping between display offsets and raw offsets means there is no offset-translation layer — caret math, selection, undo diffs, incremental reparse, and autosave all operate on one coordinate system.

Consequences you must respect:

Consequence Why
No NSTextAttachment, ever TextKit 2 only honors attachments on U+FFFC (OBJECT REPLACEMENT CHARACTER), which rawSource never contains. Images, math, bullets, checkboxes, icons are drawn as overlays instead (§4).
No inserted display characters A synthesized <br>, bullet glyph, or padding character would desync offsets. Use attributes (.kern, paragraph styles) or overlays.
Never mutate storage while IME is composing During composition, storage holds marked text so the invariant is transiently false and didChangeText defers syncing. Styling that runs beginEditing/setAttributes/invalidateLayout mid-composition strands the marked text; the invariant then stays broken and every later edit drifts the caret. Every storage-touching styling path — including async ones scheduled before composition began — must guard !hasMarkedText().

The incident. The original delete-drift bug: an async restyle fired during IME composition, stranded the marked text, didChangeText kept bailing on its own guard, and the invariant stayed silently broken — caret drift on every subsequent edit. becomeFirstResponder now resyncs from storage as a catch-all. Full write-up: docs/investigations/delete-drift-investigation.md.

Invariant 2 — TextKit 2 only

Never touch NSTextView.layoutManager (the TextKit 1 NSLayoutManager accessor) and never store NSTextBlock/NSTextTable attributes. Either one silently and permanently reverts the view to TextKit 1 — no error, no log, just different (and wrong-for-us) layout from then on.

Rationale. All custom drawing rides NSTextLayoutFragment subclassing (§4), and viewport-based layout (only on-screen content laid out) is what makes large documents fast. Both are TextKit 2 facilities; a TK1 fallback kills them.

The tripwire. DEBUG builds observe NSTextView.willSwitchToNSLayoutManagerNotification and assertionFailure if the fallback ever triggers — textKit1FallbackTripwire(_:), Sources/EdmundCore/TextView/EditorTextView.swift:272-303. If you see that assertion, some code path you touched used a TK1 API or attribute. Find it; do not suppress the assert.

Corollary: Edit-mode tables cannot use NSTextTable. Alignment is done by distributing slack via .kern on hidden pipe glyphs — Sources/EdmundCore/Rendering/EditorTextView+TableRendering.swift.


2. Render pipeline

rawSource ──BlockParser──▶ [Block] ──styleBlock per block──▶ attributed runs in storage
                                        │
                                        └─ SyntaxHighlighter (swift-markdown walker
                                           + custom parsers: callouts, ==highlight==,
                                           wikilinks, comments, footnotes, math,
                                           backslash escapes, inline HTML tags)
Stage Where
Block splitting Sources/EdmundCore/Parsing/BlockParser.swiftparse(_:previous:), parseWithDiff(...)
Span production Sources/EdmundCore/Parsing/SyntaxHighlighter.swift + +Walker.swift / +WalkerInline.swift / +CustomParsers.swift
One-block render styleBlock(_:cursorPosition:...), Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift:151; per-feature extensions in Sources/EdmundCore/Rendering/ (Callout, Code, Image, List, ListMarker, Math, Table, WikiLinks)
Orchestration Sources/EdmundCore/TextView/EditorTextView+Composition.swift

Recompose entry points (pick the narrowest that works — a full recompose resets every fragment height to an estimate, see §6):

Function Scope Used for
recompose(cursorInRaw:) Whole document Load, indent — never for undo (§3)
recomposeDirty(_:cursorInRaw:) A set of block indices, in place The workhorse; attribute-only
recomposeIncremental(cursorInRaw:...) The block(s) the caret moved between Most cursor moves
recomposeReplacing(oldRange:with:...) One contiguous text span Undo/redo restore

Lazy styling (Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift): a large dirty set styles only the viewport synchronously; the rest is finished by the idle drain (time-budgeted main-thread slices) and scroll promotion (style blocks as they enter the viewport).

Gotcha: attribute-only changes do not re-measure geometry in TextKit 2. If a restyle changed a block's height/indent, call invalidateLayout(for:) on its range or the fragment keeps a stale frame. recomposeDirty and the idle drain already do this; any new path must too.


3. Edit flow & undo

Normal edit: shouldChangeText (records a coalesced undo snapshot) → NSTextView mutates storage → didChangeText syncs rawSource and restyles the edited block(s) (Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift, +Composition.swift). Edits capture a pendingEdit on EditorTextStorage and reparse a window, not the whole document.

Undo/redo is custom (Sources/EdmundCore/TextView/EditorTextView+Undo.swift): stacks of rawSource snapshots, bypassing NSTextView's built-in undo. Restoring diffs the snapshot against current text (textDiff(old:new:), single contiguous span) and applies it with the range-bounded recomposeReplacingnever a full recompose, because a full recompose resets every fragment to a TextKit 2 height estimate and the follow-up scroll lands wrong (this was the undo/redo viewport-drift failure). The changed text drives the viewport: hold if any of it is on-screen, else center it.

AppKit does NOT pair every storage mutation with didChangeText. Proven incident (delete-drift round 4): a drag-move of selected text dropped on no valid target deletes the dragged range via shouldChangeTextreplaceCharacters and never calls didChangeText — silently freezing rawSource/blocks; every later edit drifts the caret and autosave writes stale text. The heal: shouldChangeText schedules a next-run-loop bypass check (scheduleBypassedEditSyncCheck, +EditFlow.swift) — a pendingEdit still unconsumed by then means the closing didChangeText never came, and the editor runs the same sync itself. Breadcrumb in ~/.edmund/logs: healing storage edit that bypassed didChangeText. Never build a sync path on the assumption that didChangeText follows every edit.

Round 6 corollary: a bypassed edit also leaves TextKit 2's private selection fixup (_fixSelectionAfterChangeInCharacterRange:) queued; it fires at the next endEditing — even an attribute-only restyle — and leaps the caret blocks away, moving even a freshly set valid caret. The heal sets the caret before the sync and re-asserts it after (+EditFlow.swift). This class does not reproduce headless; see the routing in §8.


4. TextKit 2 drawing model

All custom visuals are drawn by DecoratedTextLayoutFragment (custom NSTextLayoutFragment, Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift:160), vended via the layout-manager delegate. Two custom attribute keys (same file, lines 28/32):

Attribute Level Draws Rules
.blockDecoration Paragraph Callout boxes, quote bars, table borders, thematic-break rules, code backgrounds Fragments tile vertically so a multi-line run reads as one continuous box/bar. A box's bottomPad grows the last fragment's own frame — TextKit 2 omits trailing paragraph spacing from the fragment, so padding done any other way is dead space.
.fragmentOverlay Character An image or stroked vector path at a character's laid-out position: rendered math, list bullets/checkboxes, callout header icon+name image, custom-title callout icon (path) The anchor glyph is hidden (hiddenFont + clear color) and .kern reserves the drawing's advance width — the same trick the table renderer uses.

The image-wedge constraint (open, not solved). Drawing an image overlay on a multi-line (wrapping) fragment re-triggers a layout pass that wedges the fragment to one line. Drawing a shape (stroked CGPath) does not. That is why the wrapping callout custom-title icon is a stroked path parsed from vendored Lucide geometry, never an image. Any new overlay that could share a line with wrapping text must be a shape, not an image. Full saga: docs/investigations/archives/callout-title-wrap-investigation.md.


5. Read mode contract

Read mode is a separate WKWebView, not an editor styling mode (Sources/EdmundCore/Export/). The contract: one parser, two back-ends — the same swift-markdown Document the editor parses is walked by SyntaxHighlighter.SpanCollector (→ editor attributes) and by HTMLRenderer (→ HTML), themed from the same EditorTheme via HTMLTheme, so the two renderings cannot drift. When adding a feature, implement it in both back-ends or document the divergence.

Hard properties of the web view (keep them):

  • JavaScript disabled; every asset inlined (math as high-DPI PNG data URIs — SwiftMath has no SVG path; icons as inline Lucide SVG) so it needs no file/network reach. Remote images off by default (Sources/EdmundCore/Export/ReadRenderOptions.swift).
  • Private URL schemes route navigation without JS: x-edmund-wiki: / x-edmund-link: (Sources/EdmundCore/Export/HTMLRenderer.swift:26,31).
  • Inline HTML: only the whitelist SyntaxHighlighter.htmlFormatTags (u/kbd/mark/sub/sup, Sources/EdmundCore/Parsing/SyntaxHighlighter.swift:22) renders in either mode; everything else stays escaped/color-only. A real <br> break would need to mutate storage — forbidden by Invariant 1.
  • Export/Print run the same HTML through WKWebView.printOperation (Sources/EdmundCore/Export/MarkdownPrinter.swift) for vector text.

6. Known weak points (open as of 2026-07-05)

State these plainly when designing near them; none is solved.

  1. TextKit 2 height estimates are the root of most viewport glitches: an off-screen fragment's frame (and the total document height) is an estimate until layout reaches it — scroller jumps, scroll-to-target lands wrong (a documented TK2 limitation; TextEdit shows it too). Mitigations in place, not cures: documents ≤ fullLayoutMaxLength (100k UTF-16, Sources/EdmundCore/TextView/EditorTextView.swift:80) are kept fully laid out by scheduleFullLayoutSettle() wrapped in preservingViewportAnchor (+LazyStyling.swift:121, +TypewriterScroll.swift:22); repairContentAboveOrigin() (+LazyStyling.swift:151) fixes content stranded above y=0; centerViewportOnCaret re-measures after its first scroll. Never trust an off-screen fragment's y-coordinate without laying out the span first.
  2. The image-wedge constraint (§4) applies to every new overlay.
  3. Open bugs (misc/backlog.md): callout at end-of-file renders an extra un-prefixed line in the callout color (live incremental-restyle path, not static rendering); footnotes don't render in either mode; attached images create blank space below; math doesn't render in read mode (and has wrong padding in edit mode); delete caret drift and viewport-estimate glitches remain on the ongoing list.
  4. Crash reporter endpoint is a placeholder: CrashReporter.reportingEndpoint is https://REPLACE-ME.invalid/crash (Sources/EdmundCore/Diagnostics/CrashReporter.swift:27) and the Settings ▸ Advanced toggle is commented out (Sources/edmd/Settings/AdvancedSettingsView.swift). Do not treat crash uploading as live.

7. Before you design something new — checklist

Run this before proposing any new mechanism, subsystem, or refactor:

  • Invariant 1: does it insert/strip characters, use NSTextAttachment, or mutate storage outside the shouldChangeText→didChangeText path (or during IME composition)? If yes, redesign as attributes/overlays.
  • Invariant 2: does it touch NSTextView.layoutManager or store NSTextBlock/NSTextTable? If yes, stop.
  • Sync assumptions: does it assume didChangeText follows every mutation, or that a set caret stays put across the next endEditing? Both assumptions are proven false (§3).
  • Geometry: does it read an off-screen fragment frame, or restyle without invalidateLayout(for:) when height changed? (§2, §6.)
  • Both back-ends: does a rendering feature cover Edit and Read (§5)?
  • Prior art: check ARCHITECTURE.md §14 — especially nodes-app/swift-markdown-engine, an independent AppKit+TextKit 2 live-preview engine solving the same problems — before inventing a new mechanism for an editing-experience problem.
  • Weak points (§6): does the design lean on anything listed there? Label it as such; unproven mitigations are "open/candidate", never "fixed".
  • Verification plan: unit test if headless can repro; otherwise plan a live repro (ReproScript) — do not ship a caret/IME/viewport fix on reasoning alone. Visual claims are measured from screencapture pixels, not eyeballed.

Process rules live in sibling skills, but never contradict them here: branch per fix off main; never auto-push/PR/merge; swift test green + visual verification before commit; never blanket pkill -x edmd (the maintainer's daily-driver app shares the binary name — pgrep and kill only your own PID); never request macOS Computer Access permissions.


When NOT to use this skill

You need… Use instead
Whether/how to gate a change, commit discipline, scope control edmund-change-control
A symptom → cause triage path for a bug you're seeing edmund-debugging-playbook
The blow-by-blow history of a past investigation edmund-failure-archaeology
TextKit 2 / AppKit theory beyond Edmund's specific contract textkit2-appkit-reference
Launch flags, debug bundles, defaults keys edmund-config-and-flags
Build, stale-binary cures, screencapture mechanics, environment setup edmund-build-and-env
Cutting a release, Sparkle/appcast/CI edmund-release-and-operate
Driving a live repro (ReproScript, CGEvent, log tracing) edmund-live-repro-and-diagnostics
Test-writing patterns, QA passes edmund-validation-and-qa
Docs style, ARCHITECTURE.md upkeep edmund-docs-and-writing
Positioning, comparisons, marketing claims edmund-external-positioning
The caret/selection-integrity campaign specifically edmund-caret-integrity-campaign
How to investigate an unknown (method, not facts) edmund-research-methodology / edmund-research-frontier

Provenance and maintenance

Facts verified against the repo on 2026-07-05. If a grep below stops matching, the fact drifted — update this file and cite the new location.

# Invariant 2 tripwire still present
grep -n "textKit1FallbackTripwire" Sources/EdmundCore/TextView/EditorTextView.swift
# Recompose entry points
grep -n "func recompose" Sources/EdmundCore/TextView/EditorTextView+Composition.swift
# Bypass heal
grep -n "scheduleBypassedEditSyncCheck" Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
# Custom draw attributes + fragment class
grep -n "blockDecoration\|fragmentOverlay\|class DecoratedTextLayoutFragment" Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift
# hiddenFont hiding trick
grep -n "hiddenFont" Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift
# Viewport mitigations
grep -n "fullLayoutMaxLength" Sources/EdmundCore/TextView/EditorTextView.swift
grep -n "scheduleFullLayoutSettle\|repairContentAboveOrigin" Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift
# Read-mode schemes + HTML whitelist
grep -n "wikiScheme\|linkScheme" Sources/EdmundCore/Export/HTMLRenderer.swift
grep -n "htmlFormatTags" Sources/EdmundCore/Parsing/SyntaxHighlighter.swift
# Crash-reporter placeholder (delete §6.4 once this is a real URL)
grep -n "REPLACE-ME.invalid" Sources/EdmundCore/Diagnostics/CrashReporter.swift
# Open-bug list
sed -n '/^Bugs/,/^UI\/UX/p' misc/backlog.md
Edmund仓库的构建、环境与工具链操作手册。涵盖从零搭建环境(macOS 14+/Xcode 16+)、核心构建命令、build-app.sh脚本详解、解决构建陈旧问题、调试包构造及CI配置,确保原生Markdown编辑器正确编译与运行。
从零设置开发环境或新机器克隆 构建失败或构建结果陈旧未生效 应用启动崩溃或LaTeX渲染异常 需要构造EdmundDbg.app进行实时调试 验证刚构建的二进制文件
.claude/skills/edmund-build-and-env/SKILL.md
npx skills add I7T5/Edmund --skill edmund-build-and-env -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-build-and-env",
    "description": "Build, environment, and toolchain runbook for the Edmund repo (native macOS Markdown editor; AppKit + TextKit 2, SwiftPM). Load this skill when: setting up the environment from scratch (fresh clone, new machine, CI mirror); a build fails or a \"successful\" build behaves stale (change \"doesn't take\", old code runs, Build complete! but nothing changed); the app crashes on launch or the instant it renders LaTeX; you need to construct the debug bundle (EdmundDbg.app) for live runs; or BEFORE trusting any binary you just built for a visual check or repro run. Covers swift build\/test, build-app.sh anatomy (codesign sealing order, SwiftMath bundle placement), the stale-build disease and its detection, safe launch\/kill hygiene around the user's live instance, and the CI environment."
}

Edmund — build & environment runbook

All paths relative to the repo root. Facts date-stamped 2026-07-05 are volatile — re-verify per the last section.

When NOT to use this skill

You actually need Go to
The storage==rawSource / TextKit-2-only invariants, render pipeline edmund-architecture-contract
Branch/commit/PR rules, what you may touch edmund-change-control
Diagnosing a bug (not the build) edmund-debugging-playbook
ReproScript / CGEvent live-repro driving edmund-live-repro-and-diagnostics
Launch flags, defaults keys, settings edmund-config-and-flags
Cutting a release, DMG, Sparkle appcast edmund-release-and-operate
Screencapture verification method, test policy edmund-validation-and-qa

1. Environment from scratch

Requirements (verified 2026-07-05):

Tool Version Why Check
macOS 14+ Package.swift platforms .macOS(.v14) sw_vers
Xcode 16+ (full Xcode, not just CLT) swift-tools-version: 6.0; build-app.sh needs actool swift --version (local: Swift 6.0.3)
gh CLI any recent releases, PR ops gh --version
Node ≥20 releases only node --version
create-dmg npm package releases only npm install --global create-dmg

create-dmg trap: install via npm, NOT Homebrew. brew install create-dmg is a different tool with an incompatible CLI. Not needed for dev work — only for cutting releases (see edmund-release-and-operate).

Dependencies are fetched by SPM on first build — nothing to install by hand (verified against Package.swift / Package.resolved, 2026-07-05):

  • swift-markdown ≥0.5.0 (CommonMark/GFM parsing; pulls swift-cmark transitively)
  • SwiftMath ≥1.7.0 (LaTeX rendering — its resource bundle is a launch-crash landmine, §3)
  • Sparkle ≥2.6.0 (auto-update — its framework is a dyld-abort landmine, §4)

Two SPM targets: EdmundCore (library + all tests; most work happens here) and edmd (the app shell executable). The binary is named edmd even though the app presents as "Edmund" — deliberate, see the comment in Package.swift.

2. Core commands

swift build                    # debug build of both targets
swift test                     # full suite: ~750+ tests, ~10s (2026-07-05)
swift test --filter Callout    # one suite
./scripts/build-app.sh         # release build → build/Edmund.app

swift test also runs automatically as a Stop hook after code-touching turns.

3. What build-app.sh actually does (and why the order matters)

scripts/build-app.shbuild/Edmund.app. Steps, in order:

  1. swift build -c release
  2. Assemble build/Edmund.app/Contents/{MacOS,Resources}; copy .build/release/edmd, Info.plist, Resources/AppIcon.icns.
  3. Compile Resources/Assets.xcassets with actool (falls back to /Applications/Xcode.app/.../actool if xcode-select points at the CLT).
  4. Embed Sparkle.framework into Contents/Frameworks/ (found under .build/; SwiftPM links Sparkle but never copies the framework — without it the updater crashes on first check) and install_name_tool -add_rpath "@executable_path/../Frameworks" so @rpath resolves post-install.
  5. Codesign inside-out: Sparkle.framework first (nested XPC helpers must be signed before macOS will launch them), then the whole .app (ad-hoc, --deep, identifier com.i7t5.edmd). Sealing the bundle — not just the binary — is what Sparkle's update validator requires.
  6. Only after sealing: copy .build/release/*.bundle (SwiftMath's math fonts) into the .app root.

Why step 6 is last and at the root — two constraints collide:

  • codesign refuses to seal a bundle with any extra item at the .app root ("unsealed contents present in the bundle root"), so the seal must happen while the root holds only Contents/.
  • SwiftMath's generated Bundle.module accessor hardcodes Bundle.main.bundleURL — the .app root — with only a hardcoded absolute .build path as fallback. So the bundle must sit at the root.

Resolution: seal first, copy after. The one unsealed root item makes codesign --verify and --strict complain, but Sparkle's actual check is non-strict and tolerates it (verified end-to-end; details in docs/ARCHITECTURE.md §8).

Missing SwiftMath bundle = instant crash the moment the app renders any LaTeX. App launches fine, opens documents fine, dies on the first math block. If you see that crash, check ls build/Edmund.app/*.bundle first.

4. Debug bundle fast path (EdmundDbg.app)

For live runs of a debug build, skip build-app.sh and hand-assemble (from docs/dev-guides/live-repro-guide.md §4):

swift build
mkdir -p build/EdmundDbg.app/Contents/MacOS
cp Info.plist build/EdmundDbg.app/Contents/
cp .build/arm64-apple-macosx/debug/edmd build/EdmundDbg.app/Contents/MacOS/
cp -R .build/arm64-apple-macosx/debug/Sparkle.framework build/EdmundDbg.app/Contents/MacOS/
  • Sparkle.framework must sit next to the binary — dyld aborts without it.
  • A bare .build/debug/edmd runs but never creates a window. It needs the bundle (Info.plist) around it.
  • Launch by direct exec of the bundle binary, never open -a:
build/EdmundDbg.app/Contents/MacOS/edmd /path/to/test.md \
  -ApplePersistenceIgnoreState YES &

LaunchServices (open -a) can silently run a stale cached/translocated copy — you'd be executing last hour's code. Direct exec runs exactly the binary you just copied. -ApplePersistenceIgnoreState YES stops state restoration from reopening previous (possibly mutated) documents.

  • Recreate the test document fresh before every run — autosave mutates it.

5. THE STALE BUILD DISEASE

The single most expensive trap in this repo: it has produced entire wrong debugging conclusions ("my fix doesn't work" when the fix was never in the binary).

Symptom: swift build prints Build complete! having compiled a changed file but not relinked edmd. The app then runs old code. Release builds (swift build -c release / build-app.sh) reuse stale objects too.

Detection — before trusting ANY binary you just built:

# 1. Grep for a LONG string literal unique to your new code:
strings .build/arm64-apple-macosx/debug/edmd | grep 'your long unique literal'
# 2. Hash before/after the build:
shasum .build/arm64-apple-macosx/debug/edmd

Literal length matters: string literals ≤15 bytes are stored inline in the Mach-O on arm64 and never appear in strings output. A short probe literal gives a false "stale" verdict. Use a long one (a distinctive log message works well).

Cure:

swift package clean          # first resort
rm -rf .build                # visual change "doesn't take" → nuke it all

Never hand-delete .build/…/edmd.build/ — that corrupts SwiftPM's output-file-map and wedges the target until a full clean anyway.

6. Running for visual checks — launch/kill hygiene

The user's daily-driver app has the same binary name (edmd). A blanket pkill -x edmd kills their live session. Always, in order:

# 1. Who is running, and since when?
pgrep -lx edmd
ps -o lstart=,command= -p <pid>
# 2. Kill ONLY your own PID — or, if you launched the debug bundle:
pkill -f EdmundDbg

Other run gotchas:

  • open Edmund.app foregrounds a running instance instead of relaunching — you'll be looking at the old binary. Kill your instance first or direct-exec the binary.
  • Always pass -ApplePersistenceIgnoreState YES (see §4).
  • After many rapid launch/kill cycles the window server can glitch (tiny windows, broken state restoration): rm -rf ~/Library/"Saved Application State"/com.i7t5.edmund.savedState and relaunch.
  • Verification method (window-id screencapture, offscreen render fallback): edmund-validation-and-qa and docs/ARCHITECTURE.md §8.

7. CI environment

.github/workflows/ci.yml (verified 2026-07-05): runs swift test on macos-14 with latest-stable Xcode (Swift 6.0 needs Xcode 16+), triggered on PRs and pushes to main.

  • SPM cache: .build is cached keyed on spm-v2-${{ runner.os }}-${{ hashFiles('Package.resolved') }}. The v2 token exists because the repo rename mdEdmund changed the checkout path and invalidated absolute paths baked into the cached module cache — bump the token to discard a poisoned cache.
  • Concurrency: cancel-in-progress: true per branch/PR — private-repo macOS minutes bill at 10x, so superseded commits' runs are cancelled.
  • Release pipeline (release.yml, tag-triggered) is a separate beast: edmund-release-and-operate / docs/ARCHITECTURE.md §13.

8. Checklists

Fresh clone to green

  • sw_vers — macOS 14+; swift --version — Swift 6.x (Xcode 16+)
  • git clone + cd Edmund
  • swift build — SPM fetches swift-markdown, SwiftMath, Sparkle
  • swift test — ~750+ tests green in ~10s
  • Read docs/ARCHITECTURE.md (mandated by CLAUDE.md before non-trivial work)
  • Visual work planned? ./scripts/build-app.sh, confirm build/Edmund.app exists and ls build/Edmund.app/*.bundle shows the SwiftMath bundle
  • Releases planned? node --version ≥20, npm install --global create-dmg

Before trusting any run

  • Binary is fresh: strings <binary> | grep '<long unique literal>' and/or shasum changed since the edit (§5)
  • pgrep -lx edmd — user's live instance identified; you will kill only your own PID (§6)
  • Launched by direct exec, not open -a (§4)
  • -ApplePersistenceIgnoreState YES passed
  • Test document recreated fresh (autosave mutated the last one)
  • Debug bundle: Sparkle.framework sits next to the binary
  • Release bundle: SwiftMath *.bundle at the .app root (or the first LaTeX render crashes)

Provenance and maintenance

Every claim above was read from the files below on 2026-07-05. Re-verify:

  • Toolchain/deps: cat Package.swift (tools-version, platforms, dep versions); grep identity Package.resolved
  • Build anatomy: cat scripts/build-app.sh (step order, sealing comments)
  • Test count/time: swift test 2>&1 | tail -3
  • Debug bundle recipe: docs/dev-guides/live-repro-guide.md §4
  • Stale-build disease, launch gotchas: docs/ARCHITECTURE.md §8 ("Stale release builds", "open Edmund.app", savedState)
  • CI facts: cat .github/workflows/ci.yml (cache key comment, concurrency)
  • create-dmg quirks: docs/ARCHITECTURE.md §8 ("create-dmg — npm only")

If a command here disagrees with those files, the files win — update this skill.

针对Edmund项目中NSTextView/TextKit 2的删除漂移类问题(光标错位、文本不同步)的决策型调试活动。通过分类日志签名、隔离已知问题、捕获证据、构建确定性复现脚本,并从排名解决方案中选择修复方案,最终验证并推广变更。
光标或选区在删除/输入/拖拽交互后位置错误 文本编辑导致内容损坏或状态不同步 自动保存写入陈旧内容 日志中出现 LEN-MISMATCH 或绕过 didChangeText 的警告
.claude/skills/edmund-caret-integrity-campaign/SKILL.md
npx skills add I7T5/Edmund --skill edmund-caret-integrity-campaign -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-caret-integrity-campaign",
    "description": "The executable, decision-gated campaign for Edmund's hardest live problem: the delete-drift class (caret\/selection integrity in the live NSTextView \/ TextKit 2 \/ input-context layer). Load when the caret or selection lands in the wrong place after a delete\/type\/IME\/drag interaction, when text edits corrupt or desync, when autosave writes stale content, or when logs show ⚠︎LEN-MISMATCH or the \"healing storage edit that bypassed didChangeText\" breadcrumb. Runs round 7+ at the retiring maintainer's standard: classify, fence off six settled battles, capture evidence, build a deterministic repro, pick from a ranked solution menu with proof obligations, then validate and promote through change control. Not for viewport\/scroll drift (that's edmund-debugging-playbook → viewport docs) unless the caret itself moves."
}

Caret-integrity campaign (the delete-drift class)

Six rounds are settled; new rounds are expected. This class does not reproduce in headless tests — the harness runs AppKit's deferred machinery synchronously, so a green unit test proves nothing here. Success is measured by PASS/FAIL grep and byte counts, never by eye or by reasoning.

Verified 2026-07-05 against docs/investigations/delete-drift-investigation.md (456 lines) and the code. Read that doc fully before a deep dive.


QUICK CARD (the whole loop)

0. CLASSIFY   grep logs for the 4 signatures. Not this class? → debugging-playbook.
1. FENCE      check the 6 settled battles + guards still hold. Don't re-fight them.
2. EVIDENCE   run with verbose diags; find FIRST bad line; walk BACKWARDS;
              traceSelectionOrigin names who moved the caret; rebuild the doc.
3. REPRO      ReproScript: needles not offsets; real events; replicate internal
              call sequences verbatim. Freeze a script whose logsel/assertcaret
              DISCRIMINATES broken vs current build. No repro → hypothesis wrong.
4. SOLVE      rank candidates (missing guard < extend heal < reorder fixup <
              structural). Each states what it must PROVE.
5. PROMOTE    fix flips frozen repro to PASS same run → soak 4–5 cycles,
              byte-identical rawLen → swift test green → add test (locks headless
              contract even if non-discriminating) + keep .repro → update the
              investigation doc → branch/commit via change-control. Never ship on
              reasoning alone.

PHASE 0 — Classify: is it actually this class?

Grep ~/.edmund/logs/edmund-<date>.log (run the app with -settings.general.diagnosticLogging YES -settings.advanced.verboseEditorDiagnostics YES):

Signature Meaning
healing storage edit that bypassed didChangeText a bypass fired (round-4 class)
persisting ⚠︎LEN-MISMATCH (not just transient between shouldChangeText→synced) storage/rawSource desync stuck
selectionDidChange with up=Y at a surprising position selection moved mid-recompose (round-6 class)
a shouldChangeText with no synced/SKIPPED/DEFERRED after it a bypassed didChangeText

scripts/grep-trace.sh in edmund-live-repro-and-diagnostics surfaces all four at once.

If none match and the symptom is scroll/viewport-shaped (jump, wrong landing, can't-scroll-up) with the caret not leaping → this is the viewport class, not caret integrity → edmund-debugging-playbook + docs/investigations/viewport-glitch-investigation.md. Do not run this campaign on a viewport bug.


PHASE 1 — Fence off the six settled battles

Confirm each shipped guard still holds before hypothesizing a new mechanism. The most likely round-7 shape is a new code path that lacks an existing guard, not a new mechanism.

Round Mechanism (settled) The shipped guard — confirm it still covers your path
1–3 IME marked-text stranding: styling that runs beginEditing/setAttributes/invalidateLayout while hasMarkedText() strands the composition; didChangeText then bails forever and every edit drifts every storage-touching styling path guards !hasMarkedText() — including async ones scheduled before composition began (+SelectionTracking caret-move restyle). becomeFirstResponder resyncs as catch-all
4 Drag-move bypass: a drag-move whose drop has no valid target deletes via shouldChangeTextreplaceCharacters with no didChangeText; rawSource/blocks freeze; autosave writes stale text scheduleBypassedEditSyncCheck (+EditFlow.swift): a next-run-loop check finds an unconsumed storage pendingEdit and runs the sync (breadcrumb above)
5 Heal leaped the caret: the round-4 heal ran against a stale selection and moved the caret heal collapses/derives the caret from the pendingEdit hull before syncing
6 Queued selection fixup: TK2's _fixSelectionAfterChangeInCharacterRange stays queued after a bypass and fires at the next endEditing (even an attribute-only restyle), remapping the stale selection and leaping even a freshly set, valid caret heal sets the caret from the pendingEdit hull before the sync AND re-asserts it after (+EditFlow.swift)
7 Same queued fixup on the NORMAL edit path (not the heal): armed by a cross-block caret move (schedules the async caret-move restyle), the fixup fires during syncRawSourceFromDisplayrecomposeDirty's endEditing on an ordinary keystroke and leaps the caret to the block boundary; the normal path styles settingSelection=false and never re-asserts, so it persists syncRawSourceFromDisplay captures the pendingEdit-hull caret before consumePendingEdit, re-asserts it after recomposeDirty if the fixup moved it (+EditFlow.swift; breadcrumb re-asserting caret after fixup leap (normal path)) — fix not yet confirmed by a deterministic repro, live-verifiable via the breadcrumb

Confirm the guards exist:

grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/
grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift

FENCED WRONG PATHS (each proven dead — do not retry)

  • Fixing by nudging the caret at symptom time. The symptom is armed seconds-to-minutes earlier (round 6: drift at 22:13 armed at 22:11:57). You'd patch the wrong instant.
  • Trusting a headless regression test. The round-6 test passes with and without the fix. Headless runs the fixup synchronously.
  • Shipping on reasoning alone. Rounds 1–5 all came back. Round 6's first fix candidate "worked by reasoning" and failed in the repro within a minute.
  • Assuming didChangeText pairs with every mutation. AppKit violates this (round 4).
  • Mutating storage mid-composition. That is the original bug (rounds 1–3).
  • Expecting a scripted keystroke replay to arm round 7. Round 7 was replayed faithfully from a reconstructed t0 (every keystroke + capped pauses through the whole drift window) and it did not produce the block-end leap. Programmatic caret moves (clickoff) and imprecise synthetic clicks (realclickoff — lands off-by-a-few, diverges, crashes) do not fully arm it. The arming needs real mouse clicks at real positions and probably the real session's two-document window switching (becomeFirstResponder resync is the prime suspect). Round-8 lead: two open docs + real clicks, or instrument becomeFirstResponder/the caret-move restyle to catch the next live occurrence. Compressed replays also coalesce a bypass with the next keystroke into one pendingEdit hull (never happens live — the heal runs first), producing off-by-one breadcrumb noise; don't trust it as a repro.

PHASE 2 — Capture evidence

  1. Launch with verbose diagnostics (debug bundle; file arg is argv[1]):
    build/EdmundDbg.app/Contents/MacOS/edmd DOC.md \
      -settings.general.diagnosticLogging YES \
      -settings.advanced.verboseEditorDiagnostics YES \
      -ApplePersistenceIgnoreState YES
    
  2. Decode the trace fields: sel/active/marked/up/undo/blocks/storLen/rawLen. up=Y = event arrived mid-recompose (suspicious). Healthy ordering: shouldChangeTextselectionDidChange (up=N)synced; a transient ⚠︎LEN-MISMATCH between those is normal, a persisting one is not.
  3. Find the FIRST bad line, then walk BACKWARDS. The visible symptom is often the second half of a two-part mechanism.
  4. traceSelectionOrigin logs the call stack of whoever moved the selection mid-recompose — this is what named _fixSelectionAfterChange in round 6. If the caret moves and you don't know who moved it, this answers it in one run.
  5. Reconstruct the document. Wrapped-paragraph geometry and block kinds matter — repro against a lookalike, never "hello world".

Gate: you have a candidate trigger hypothesis + the first-bad-line timestamp. Otherwise instrument (add a Log.shouldTrace breadcrumb — one call stack or state dump beats ten speculative fixes) and wait for the next occurrence.


PHASE 3 — Build the deterministic repro

Use the in-process ReproScript driver (Sources/edmd/App/ReproScript.swift, DEBUG only; full guide in edmund-live-repro-and-diagnostics). Commands: sleep / caret / type / backspace / bypassdelete / assertcaret / logsel.

Worked example — the round-6 minimal repro (the first deterministic repro in six rounds):

sleep 2000
bypassdelete Sizemore,
sleep 800
logsel            # broken build: 321   fixed build: 290
backspace 2
logsel

The deciding output was logsel 321 → 290 with the fix, every run, window not even visible.

Rules that make repros survive editing:

  • Needles, not offsets — offsets go stale the instant the script edits.
  • Real events, not method shortcutsinsertText("") skips deleteBackward's selection machinery, exactly where round 6 lived.
  • Replicate AppKit-internal call sequences verbatimbypassdelete does shouldChangeTextreplaceCharacters, no didChangeText, not an approximation. For a new internal path, pin its real sequence from a traceSelectionOrigin stack first, then replay it (extend ReproScript ~10 lines).

Gate: a frozen script (exact commands + document) whose logsel / assertcaret PASS/FAIL output discriminates the broken build from the current one.

No repro after honest attempts? The hypothesis is wrong, or fidelity is too low — a mouse-only path (real drag-select/drag-move) needs the CGEvent driver (edmund-live-repro-and-diagnostics §4). Loop back to Phase 2.


PHASE 4 — Solution menu (ranked; each with a proof obligation)

Pick by the observation pattern. Cheaper is higher.

  1. Add a missing guard on an existing invariant (cheapest, most likely for a new round). Guard inventory: !hasMarkedText() on the offending styling path; bypass-check scheduling on a new mutation entry point; caret re-assertion after a restyle. Proof: the frozen repro flips to PASS and no other .repro regresses. Selects when: a specific new code path shows the round-1/4/6 signature that its siblings already guard.
  2. Extend the heal to a new bypass source. Proof: first add a ReproScript command that replays the new source's exact call sequence, show it reproduces the freeze, then show the extended heal fixes it. Selects when: trace shows a shouldChangeText with no close-out from a path other than drag-move.
  3. Intercept/reorder the queued fixup. Proof obligation: explain when TK2 queues and fires _fixSelectionAfterChange and show the reorder does not fight AppKit's machinery (no oscillation, no double-move). Selects when: traceSelectionOrigin names the fixup and the caret is valid before it fires. Higher risk — you are stepping into private AppKit ordering.
  4. Structural: make rawSource sync independent of didChangeText pairing (e.g. a storage-version counter that reconciles regardless of which callbacks fired). Biggest change; candidate, unproven — route through edmund-research-frontier (frontier item "caret integrity by construction"). Proof: all historical .repro scripts + a randomized bypass-fuzzer soak stay green with the callback-pairing assumption removed.

PHASE 5 — Validate and promote

  1. Fix candidate flips the frozen repro to PASS within the same run. (If it only "works by reasoning," it is not done — round history.)
  2. Soak (edmund-live-repro-and-diagnostics §3): one script, one run, 4–5 trigger cycles at different document positions with ordinary editing between, assertcaret after every predictable step. Green across all cycles and byte-identical final rawLen across repeated runs = deterministic.
  3. swift test green (also the Stop hook).
  4. Add a regression test even if it can't discriminate the live mechanism — it locks the headless contract (assert rawSource == string; see the BypassedEditSyncTests / MarkedTextDesyncTests family). Keep the .repro script in the repo for the live half.
  5. Update docs/investigations/delete-drift-investigation.md with the new round (symptom → root cause → repro recipe → fix → status), and docs/ARCHITECTURE.md §8 if an invariant changed — same PR.
  6. Route through edmund-change-control: branch fix/… off main, small commits, never auto-push/PR/merge. (No screencapture unless the fix also draws.)

Never promote a fix that only "works by reasoning."


When NOT to use this skill

  • Viewport/scroll drift where the caret itself does not leap → edmund-debugging-playbook + docs/investigations/viewport-glitch-investigation.md.
  • Just need the repro driver mechanics / trace decoding → edmund-live-repro-and-diagnostics.
  • The AppKit theory behind the mechanisms → textkit2-appkit-reference.
  • The historical record of prior rounds → edmund-failure-archaeology.
  • The structural (round-∞) redesign as a research project → edmund-research-frontier + edmund-research-methodology.

Provenance and maintenance

Verified 2026-07-05 against docs/investigations/delete-drift-investigation.md, Sources/edmd/App/ReproScript.swift, and Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift.

grep -nE '^## Round' docs/investigations/delete-drift-investigation.md          # round chronicle
grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/
grep -oiE '"(bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u
# round-6 discriminating numbers (321 broken / 290 fixed):
grep -n '321\|290' docs/investigations/delete-drift-investigation.md

When round 7 lands, add its row to Phase 1 and its dead ends to the fenced list.

规范Edmund仓库的代码变更分类、审查门禁及验证流程。涵盖文档、逻辑、视觉渲染及编辑行为等变更类型的测试与截图要求,强调历史教训与非协商规则,防止因忽视特定验证步骤导致的回归问题。
准备修改仓库代码或配置前 提交代码、创建分支或发起合并请求前 决定变更是否需要测试、截图或实时复现时 不确定是否需联系维护者处理推送、发布或删除工作时
.claude/skills/edmund-change-control/SKILL.md
npx skills add I7T5/Edmund --skill edmund-change-control -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-change-control",
    "description": "How changes are classified, gated, and reviewed in the Edmund repo. Load at the START of any task that will modify the repo; before committing, branching, or proposing a merge or release; when deciding whether a change needs a test, a screencapture visual check, or a live repro; when unsure what requires explicitly asking the maintainer (push, PR, merge, release, deleting uncommitted work, touching test-files\/, adding dependencies). Contains the non-negotiable rules with the historical incident behind each one."
}

Edmund change control

Last verified: 2026-07-05, against main @ fe8a1f5 (release 0.1.3).

This is the gatekeeping doc: what class of change you are making, which gate it must pass, and the rules that are never traded away. The rules exist because each was paid for — the incident column is not decoration.

0. Before you start (task setup)

[ ] Read docs/ARCHITECTURE.md before any non-trivial change (its own rule)
[ ] git status — note any uncommitted work; never clobber it
[ ] Not on main? Fine. On main? Branch NOW, before the first edit
[ ] Classify the change (§1) so you know the gate before you write code
[ ] Edit-pipeline / selection work? Plan the live repro FIRST — if you can't
    reproduce the bug, you can't prove the fix (§3, last row)

1. Classify the change, apply the gate

Classify FIRST, before writing code. The class decides the verification bar.

Class Examples Gate before commit
Docs-only ARCHITECTURE.md, README, docs/*.md, comments None beyond review. swift test still runs as a Stop hook; ignore no failures it surfaces.
Code (logic) change Parser, block model, helpers, non-drawing refactor swift test green. New behavior or bug fix → add a test that fails without the change.
Visually-drawing change Anything in Rendering/, overlays, decorations, padding, fonts, layout fragments All of the above, PLUS build the app and screencapture the result (window-by-id method — see edmund-live-repro-and-diagnostics). Headless layout is not proof for anything that draws.
Edit-pipeline / selection behavior +EditFlow, +Composition, +SelectionTracking, +Undo, caret, IME, drag, viewport timing All of the above, PLUS a live repro or soak script (-debug.reproScript, see edmund-caret-integrity-campaign and docs/dev-guides/live-repro-guide.md). Headless tests cannot exercise deferred AppKit machinery — the queued selection fixup, drag paths, IME. Rounds 1–5 of delete-drift shipped on tests + reasoning; all recurred.
Release Version bump, tag, appcast Run misc/before-you-release.md top to bottom, then misc/how-to-release.md. See edmund-release-and-operate. Never start a release without being asked.

Notes on the gates:

  • swift test (~750+ tests, ~10s) also runs automatically as a Stop hook (.claude/settings.json: swift test 2>&1 | tail -5) at the end of every turn. That is a safety net, not the gate — run it yourself before committing so the failure is yours to see, not the hook's.
  • A change can be in multiple classes. Apply the union of gates. A caret fix that also moves a decoration needs test + screencapture + live repro.
  • "Draws" is broad: padding, insets, colors, wrapping, fragment frames. If a human could see the diff, screenshot it.

Classification edge cases that have gone wrong before:

  • "It's just an attribute change" is NOT automatically the code-logic class. If the attributes change measured geometry (font size, paragraph spacing, hidden-delimiter width), it draws AND it needs invalidateLayout(for:) — see §3.
  • "It's just a restyle helper" that runs beginEditing/setAttributes on storage is edit-pipeline class if it can fire during IME composition or after a bypassed edit. When in doubt, grep for hasMarkedText guards on the sibling paths and match them.
  • A test-only change is docs-class for gating purposes (nothing to screenshot), but the Stop hook still must pass — a broken test is a broken commit.
  • Release-adjacent edits (Info.plist versions, CHANGELOG.md, appcast.xml, scripts/release.sh, .github/workflows/) are release class even when tiny. The v0.1.0→0.1.1 Sparkle failure came from the build script's signing step, not from app code.

2. Git discipline

From CLAUDE.md + ARCHITECTURE §12 + the repo's own history (git branch -a).

  • Branch off main for every fix. Never commit straight to main. One feature/fix per branch.
  • Branch prefixes actually in use (verified): fix/, feat/, feature/, docs/, chore/, uiux/, ui/, ux/, markdown/, bug/, refactor/, ci/, release/. Prefer fix/, feat/, docs/, chore/ for new work; uiux/ for visual polish; markdown/ for syntax-feature work.
  • Small, logical commits; commit frequently. A commit that mixes the fix with a drive-by refactor is two commits done wrong.
  • NEVER auto-push, open a PR, or merge. Only when the maintainer explicitly asks. No exceptions for "it's just docs."
  • Never discard uncommitted changes. No git checkout -- ., git reset --hard, git clean on a dirty tree without explicit permission.
  • Commit messages follow the observed style: fix(editor): …, docs: …, ui: …, chore: … — short imperative subject.

3. The non-negotiables

Each rule was established by an incident. Verify against the cited doc before arguing an exception.

Rule Rationale Incident
Text storage always equals rawSource; rendering is attribute-only. Never insert/delete display characters — hide delimiters, never strip them. Display offset == raw offset (identity mapping) is what every selection, sync, and heal path assumes. Break it and every later edit drifts. The delete-drift saga: six rounds over months, each recurrence traced to storage/rawSource divergence in some path. docs/investigations/delete-drift-investigation.md.
TextKit 2 only. Never touch NSTextView.layoutManager; never store NSTextBlock/NSTextTable attributes. Either one silently and permanently reverts the view to TextKit 1. A DEBUG tripwire asserts if TK1 engages — heed it. ARCHITECTURE §2; the tripwire exists because the reversion is otherwise invisible.
No NSTextAttachment. Images/icons are drawn as overlays. TK2 only honors attachments on U+FFFC, which rawSource never contains (see rule 1). ARCHITECTURE §2, §5.
Never draw images on wrapping (multi-line) fragments; use stroked CGPaths instead. A TK2 image on a wrapping fragment wedges layout — collapses the fragment to one line. Shapes don't trigger it. The callout custom-title icon: docs/investigations/archives/callout-title-wrap-investigation.md; fix in ae61644 (stroked path, not image).
Every storage-touching styling path guards !hasMarkedText() — including async paths scheduled before composition began. Mutating storage mid-IME-composition strands the marked text; didChangeText then bails forever on its own guard and every later edit drifts. Delete-drift rounds 1–2 (IME stranding cascade). docs/investigations/delete-drift-investigation.md.
Never assume didChangeText follows every edit. Sync paths must survive a bypassed edit. AppKit's drag-move-to-nowhere deletes via shouldChangeTextreplaceCharacters and never calls didChangeText, silently freezing rawSource. The heal (scheduleBypassedEditSyncCheck in +EditFlow) exists for this. Delete-drift round 4 (9f99795); rounds 5–6 hardened the heal itself.
Attribute-only restyles that change geometry must invalidateLayout(for:) the range. TK2 does not re-measure on attribute change; the fragment keeps a stale frame — empty bands, clipped lines. ARCHITECTURE §8; recomposeDirty and the idle drain already do this — new paths must too.
Undo restore is diff-based recomposeReplacing — never a full recompose. Full recompose resets every fragment to a TK2 height estimate; the follow-up scroll lands wrong and the viewport drifts. Undo/redo viewport drift — one of the costliest failures here. Fixed in 5bb2b40 (fix(undo): select + center the changed text; diff-based snapshot restore).
Never blanket pkill -x edmd. pgrep -x edmd first, check start times, kill only the PID you launched. The maintainer's daily-driver app shares the binary name. A blanket pkill kills their editor with their work in it. (ARCHITECTURE §1's pkill -x edmd shorthand predates this rule — don't copy it.) Established after the maintainer's own instance was killed during a debugging session.
Never request macOS Computer Access (Screen Recording / Accessibility). Both are already granted to the tools you use. Requesting again re-prompts the maintainer and can wedge TCC state. CLAUDE.md "Environment"; the -debug.reproScript driver exists precisely so repros need no new TCC grants.
Visual judgments ("balance the padding", "is it centered") are MEASURED from screencapture pixels, not eyeballed. Eyeballed "looks right" repeatedly shipped asymmetric spacing. Crop the window, count pixels, state the numbers. Maintainer's explicit rule from UI-polish rounds (status-bar / table-padding branches).
Files in test-files/ are the maintainer's manual test corpus. Never rewrite them for automation; test-files/todo.md especially is owner-edited. They encode the maintainer's by-hand regression walk. Automation churn destroys that. Create your own fixtures in a scratch dir or Tests/. Standing maintainer rule.
Never ship a fix for a live-input-layer bug (caret, IME, drag, selection timing) on reasoning alone. A frozen repro script must falsify the bug before and confirm the fix after. This bug class does not reproduce headless (the test harness runs TK2's queued fixup synchronously). Reasoning about deferred AppKit machinery has a ~0% shipping record here. Delete-drift rounds 1–5 each shipped a plausible fix; each came back. Round 6 finally held because the ReproScript driver reproduced the drift deterministically first. docs/dev-guides/live-repro-guide.md.

The two incidents that shaped this table

Worth knowing as stories, because the rules read as pedantry until you see the cost:

  • Delete-drift (six rounds). The hardest live problem this repo has had. A caret that drifted after deletes. Round 1 blamed IME stranding — plausible fix, shipped, recurred. Round 2 disabled remaining marked-text sources — recurred. Round 3 stopped guessing and built diagnostics (selection tracing, event logs). Round 4's diagnostics caught a drag-move deleting storage with no didChangeText — the heal was born. Round 5: the heal itself leaped the caret via a stale selection. Round 6 found the actual drift mechanism — TextKit 2's queued _fixSelectionAfterChange firing at the next endEditing — and held only because the ReproScript driver could replay the exact keystroke sequence deterministically. Five shipped fixes failed; the one preceded by a frozen repro stuck. That asymmetry IS the change-control policy for this bug class.
  • Undo/redo viewport drift. Undo restored a snapshot via full recompose, which reset every fragment to a TK2 height estimate; the follow-up scroll-to-caret then landed wrong and the viewport jumped. The fix (5bb2b40) diffs the snapshot against current text and applies only the changed span with recomposeReplacing. Moral: in TK2, layout state is part of the document state you must preserve — "re-render everything" is never the safe fallback here, it is the bug.

4. Review expectations

  • ARCHITECTURE.md is updated in the same PR whenever you learn something non-obvious or change an invariant. The doc's own header demands this; the gotchas in §8 all arrived this way.
  • Quirks are documented as comments at the code site — the edge case, the workaround, the why. Not in commit messages, not in CLAUDE.md.
  • New known issues go in ARCHITECTURE §9 with a one-line repro and a pointer to any deeper write-up in docs/.
  • Big investigations (multi-round bugs) get a docs/*-investigation.md chronicle — see edmund-failure-archaeology for the pattern.

5. Pre-commit checklist (copy-paste)

The workflow that worked (ARCHITECTURE §12 + CLAUDE.md). Run it verbatim:

[ ] swift test — all green (also enforced by the Stop hook; don't rely on it)
[ ] New behavior / bug fix → a test exists that fails without the change
[ ] Draws anything? → build app, screencapture window-by-id, look at the PNG
[ ] Edit-pipeline / selection change? → live repro or soak script passed
[ ] On a branch off main (fix/…, feat/…, docs/…, chore/…), NOT on main
[ ] Diff touches only what the task needs; style matches surroundings
[ ] Learned something non-obvious? → ARCHITECTURE.md updated in this change
[ ] Quirk introduced/found? → comment at the code site
[ ] Commit is small and logical; message matches repo style (fix(scope): …)
[ ] NOT pushing, NOT opening a PR, NOT merging (unless explicitly asked)

6. Ask the maintainer first — always

Never do these unprompted; ask and wait for an explicit yes:

Action Why it's gated
git push, opening a PR, merging anything Standing rule in CLAUDE.md ("Never auto-push, PR, or merge"). The maintainer reviews and merges.
Starting or tagging a release A tag push fires CI, builds, signs, publishes a GitHub Release, and updates the appcast that live users poll. Not reversible quietly.
Deleting anything uncommitted (files, stashes, working-tree changes) "Never delete uncommitted changes" — the maintainer's in-progress work may be in the tree.
Editing anything in test-files/ Manual test corpus; todo.md there is owner-edited. Make fixtures elsewhere.
Adding a dependency Current set is deliberately three (swift-markdown, SwiftMath, Sparkle); each new one is a codesign/bundle/update-pipeline liability (see the SwiftMath bundle saga, ARCHITECTURE §8).
Changing .claude/settings.json hooks or permissions Alters what runs automatically on the maintainer's machine.

When NOT to use this skill

You need… Go to
The invariants' full technical statement and render pipeline edmund-architecture-contract
To debug a failure, read traces/logs edmund-debugging-playbook
The history of a past incident in depth edmund-failure-archaeology
TextKit 2 / AppKit API behavior details textkit2-appkit-reference
Launch flags, debug bundle, settings edmund-config-and-flags
Build issues, stale binaries, environment edmund-build-and-env
Executing a release / operating the app edmund-release-and-operate
The screencapture / ReproScript mechanics edmund-live-repro-and-diagnostics
Test-writing patterns and QA strategy edmund-validation-and-qa
Writing docs / chronicles edmund-docs-and-writing
Caret/selection bug-class specifics edmund-caret-integrity-campaign

Provenance and maintenance

  • Sources: CLAUDE.md (repo root), docs/ARCHITECTURE.md §1 §2 §8 §9 §12, .claude/settings.json (Stop hook), misc/before-you-release.md, misc/how-to-release.md, docs/investigations/delete-drift-investigation.md (rounds 1–6), docs/investigations/archives/callout-title-wrap-investigation.md, git log / git branch -a as of fe8a1f5 (2026-07-05).
  • Commits cited were verified in git log: 5bb2b40 (diff-based undo restore), 9f99795 (round-4 heal), ae61644 (stroked-path callout icon), 1b1420a (round-6 caret re-assert).
  • Two rules rest on maintainer statements rather than repo docs: the measure-from-pixels rule and the test-files/ ownership rule. If either gets written into ARCHITECTURE.md, point at it here.
  • Maintain: when a new incident produces a new rule, add a row to §3 with the incident pointer in the same PR that adds the rule to ARCHITECTURE.md. When the Stop hook in .claude/settings.json changes, update §1's note.
Edmund Markdown编辑器配置与标志目录。涵盖UserDefaults键、启动参数、编译时开关及日志配置,用于添加设置、排查行为、调试或审计默认值。强调代码为唯一事实来源,需通过grep验证最新数据。
添加或更改编辑器设置 查找控制特定行为的标志 使用调试标志启动应用 审计默认配置值 将新偏好集成到实时编辑器
.claude/skills/edmund-config-and-flags/SKILL.md
npx skills add I7T5/Edmund --skill edmund-config-and-flags -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-config-and-flags",
    "description": "Catalog of every configuration axis in the Edmund Markdown editor — user settings (UserDefaults keys, defaults, where consumed), launch arguments (diagnostic + repro flags), compile-time gates, and logging config. Load when adding or changing a setting, hunting which flag controls a behavior, launching the app with debug flags, auditing defaults, or wiring a new preference into the live editor. This skill drifts fastest of the set — every table ends with a re-verification grep. Not for the invariants (see edmund-architecture-contract), release flags (edmund-release-and-operate), or how to READ the logs (edmund-live-repro-and-diagnostics)."
}

Edmund configuration & flags

Ground-truth catalog of every knob. Code wins over docs — every value below was read from source on 2026-07-05; re-verify with the greps at the end before trusting a value in a decision.

Two source-of-truth files:

  • Sources/edmd/Settings/AppSettings.swift — every UserDefaults key + typed accessor.
  • Sources/edmd/Settings/*View.swift (Appearance / General / Advanced) + FontSettings — the SwiftUI panes (@AppStorage).

Definitions used below: UserDefaults = macOS per-app persisted key/value store; argument domain = passing -<key> <value> on the command line overrides that default for one launch; @AppStorage = SwiftUI wrapper binding a view to a UserDefaults key.


1. User settings (UserDefaults keys)

Every key is a static let in AppSettings.swift. The key string (not the Swift name) is what you pass as a launch arg.

Swift name Key string Purpose
reopenWindows settings.general.reopenWindows Reopen last windows on launch
startupAction settings.general.startupAction What to do at startup (new doc / reopen / nothing)
autoSaveWithVersions settings.general.autoSaveWithVersions NSDocument autosave-in-place vs versions
conflictResolution settings.general.conflictResolution File-changed-on-disk handling
suppressInconsistentLineEndingWarning settings.general.suppressInconsistentLineEndingWarning Silence mixed-line-ending warning
diagnosticLogging settings.general.diagnosticLogging On/off for file logging (opt-out; see §4)
logRetention settings.general.logRetention Days of logs to keep (pruned on configure)
appearanceMode settings.appearance.mode Light / dark / system
maxContentWidthCm settings.appearance.maxContentWidthCm Max column width, stored in CENTIMETRES (see note)
contentWidthUnit settings.appearance.contentWidthUnit Display unit only (cm/in); the stored value is always cm
renderBlankLinesAsBreaks settings.reading.renderBlankLinesAsBreaks Read-mode blank-line handling
sourceMode settings.view.sourceMode When on, Source replaces Edit in the ⌘E toggle; honored on open
verboseEditorDiagnostics settings.advanced.verboseEditorDiagnostics Verbose editor trace (see §4; pairs with diagnosticLogging)
sendCrashLogs settings.advanced.sendCrashLogs Opt-in crash upload — currently INERT (see note)
sentCrashReports settings.advanced.sentCrashReports Dedup set of already-uploaded .ips filenames
lastWindowHeight settings.window.lastHeight Persisted window sizing (see the frame-not-content trap)
automaticallyChecksForUpdates SUAutomaticallyChecksForUpdates Sparkle's own key (not namespaced)

Content width (the physical-column design): persisted as centimetres (maxContentWidthCm); contentWidthUnit is a display unit only. The column is an absolute physical cap converted to points via the display's real PPI (NSScreen.physicalPPI, from CGDisplayScreenSize), applied as a symmetric textContainerInset.width. Default is locale-aware — 5 in (US) / 12 cm (elsewhere) — and doubles as the slider's magnetic snap point. Recomputed on resize and on NSWindow.didChangeScreenNotification. Consumer path lives in EditorTextView+ContentWidth.swift.

Window sizing trap: persistence must round-trip the frame size, not the contentView.bounds size — reapplying content size grows the window by the title-bar + toolbar height on every reopen, and heights below minSize get silently rejected. Save window.frame.size, reapply with window.setFrame(_:) after the toolbar is installed. (Note: the key on disk is settings.window.lastHeight — code, not the lastWindowSize some docs say.)

Crash uploading is inert: sendCrashLogs defaults off AND the Settings ▸ Advanced toggle is commented out in AdvancedSettingsView.swift, and CrashReporter.reportingEndpoint is a REPLACE-ME.invalid placeholder (CrashReporter.swift:27, // TODO: real server). Nothing uploads today. Un-inert it only when a receiving server exists (see edmund-release-and-operate).


2. Launch arguments

macOS reads -<UserDefaults-key> <value> into the argument domain. Pass the key string from §1, not the Swift name. The file to open must be argv[1] (before the flags).

build/EdmundDbg.app/Contents/MacOS/edmd FILE.md \
  -settings.general.diagnosticLogging YES \
  -settings.advanced.verboseEditorDiagnostics YES \
  -debug.reproScript SCRIPT.repro \
  -ApplePersistenceIgnoreState YES
Flag Effect
-settings.general.diagnosticLogging YES Turn on file logging for this run
-settings.advanced.verboseEditorDiagnostics YES Emit the verbose editor trace (sel/active/marked/up/…)
-debug.reproScript <path> DEBUG builds only — replay a keystroke script (ReproScript.swift)
-ApplePersistenceIgnoreState YES Apple's flag — stop state restoration reopening mutated docs

-debug.reproScript is the only Edmund-specific debug key; it does not have an AppSettings accessor — it is read directly in ReproScript.swift. It is compiled out of release builds.


3. Compile-time axes

#if DEBUG gates live in: EditorTextView.swift, EditorTextView+EditFlow.swift, Diagnostics/Log.swift, edmd/App/main.swift, edmd/App/ReproScript.swift. What they gate:

  • The TextKit-1 tripwire (EditorTextView.swift:273+): a DEBUG observer on NSTextView.willSwitchToNSLayoutManagerNotification that asserts if the view ever falls back to TextKit 1. Ships only in DEBUG; the fallback itself is silent and permanent (see edmund-architecture-contract).
  • ReproScript — the whole in-process keystroke driver.
  • Log level thresholdLog.swift compiles a lower floor in DEBUG (debug+) than release (info+); see §4.

Named tuning constants (not user-facing, but they behave like knobs):

Constant Value Where Meaning
fullLayoutMaxLength 100_000 EditorTextView.swift:80 Docs ≤ this many UTF-16 units are kept fully laid out (below the TK2 estimate regime). Consumed at +LazyStyling.swift:133.

4. Logging config

Read Sources/EdmundCore/Diagnostics/Log.swift and EditorTextView+Diagnostics.swift.

  • API: Log.{debug,info,error}(_:category:), Log.measure(_:) { … }.
  • File: ~/.edmund/logs/edmund-YYYY-MM-DD.log, written on a private serial queue.
  • Config flow: AppSettings.applyLogging() pushes the toggle + retention into Log.configure at launch and on change; retention pruning happens there.
  • Two independent switches: diagnosticLogging (writes anything at all) and verboseEditorDiagnostics (adds the per-event editor trace). For a live repro you almost always want both on. Verbose lines are gated behind Log.shouldTrace.
  • The log is opt-out (on by default), retention-pruned; the user only toggles it and picks a retention window in Settings ▸ General ▸ Diagnostics.

Trace-field decoding (sel/active/marked/up/undo/blocks/storLen/rawLen, ⚠︎LEN-MISMATCH, traceSelectionOrigin) is covered in edmund-live-repro-and-diagnostics and edmund-debugging-playbook — one home per fact; this skill only says which flags turn the trace on.


5. ReproScript command surface

Sources/edmd/App/ReproScript.swift, DEBUG only. One command per line, # comments allowed.

Command Effect
sleep <ms> wait before next command
caret <needle> place caret before first occurrence of <needle>
type <text> one real key event per char (~80 ms apart)
backspace <n> n real delete keystrokes (~300 ms apart)
bypassdelete <needle> simulate drag-move source deletion: shouldChangeText + storage mutation, no didChangeText
assertcaret <needle> log PASS/FAIL iff caret sits exactly before <needle>
logsel log selection, rawSource length, doc count

Adding a command is ~10 lines in ReproScript.swift. Usage, soak patterns, and launch recipe: edmund-live-repro-and-diagnostics.


6. How to ADD a setting (checklist)

Worked from an existing real path (sourceMode / content width). To add a user-facing setting:

  1. Add a static let key + typed accessor in AppSettings.swift (namespace the key string: settings.<area>.<name>).
  2. Bind it in the relevant SwiftUI pane with @AppStorage(AppSettings.<key>).
  3. If it must affect open documents live, add/extend an applyTo… broadcast (see the font/line-height/content-width applyTo… helpers) so every open Document.editor picks it up — a setting that only takes effect on next open is usually a bug.
  4. Pick a sane default (register it, or make the accessor default when absent).
  5. New behavior needs a test + (if it draws) a screencapture check — route through edmund-change-control and edmund-validation-and-qa.

When NOT to use this skill

  • Understanding why an invariant exists → edmund-architecture-contract.
  • Release/signing/appcast flags, RELEASE_TOKEN, Sparkle keys → edmund-release-and-operate.
  • Interpreting log output / running a repro → edmund-live-repro-and-diagnostics.
  • Which change needs which gate → edmund-change-control.
  • Build-time flags in the toolchain sense (stale builds, swift package clean) → edmund-build-and-env.

Provenance and maintenance

Verified 2026-07-05 against source. This skill drifts fastest — re-verify each table before relying on it:

# §1 all keys + strings:
grep -nE 'static let [a-zA-Z]+ = "[a-zA-Z0-9._]+"' Sources/edmd/Settings/AppSettings.swift
# §1 crash toggle still commented out / endpoint still placeholder:
grep -n 'Crash reports:' Sources/edmd/Settings/AdvancedSettingsView.swift
grep -n 'reportingEndpoint' Sources/EdmundCore/Diagnostics/CrashReporter.swift
# §2 repro flag key:
grep -n 'debug.reproScript' Sources/edmd/App/ReproScript.swift
# §3 tripwire + constant:
grep -n 'willSwitchToNSLayoutManager' Sources/EdmundCore/TextView/EditorTextView.swift
grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
# §5 repro commands:
grep -oiE '"(sleep|caret|type|backspace|bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u

Known doc-vs-code discrepancies (code wins): ARCHITECTURE §7 calls the window-size key lastWindowSize; the code key is settings.window.lastHeight (lastWindowHeight). If you touch window persistence, trust the code.

Edmund编辑器Bug排查手册,提供症状到机制的初步诊断、日志分析、构建检查及已知Bug核对流程。用于快速定位输入、渲染、滚动等异常,避免重复发现已知问题。
收到Bug报告或意外行为反馈 需要对新出现的软件缺陷进行初步分类和排查
.claude/skills/edmund-debugging-playbook/SKILL.md
npx skills add I7T5/Edmund --skill edmund-debugging-playbook -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-debugging-playbook",
    "description": "Load this FIRST when any bug report or unexpected behavior arrives in Edmund: caret lands in the wrong place after delete\/typing, viewport jumps or scroll-to-target misses, rendering is wrong or missing, empty bands or clipped lines, everything suddenly renders as plain text, app crashes or won't launch, a code change \"doesn't take\" after rebuild, undo\/redo lands the viewport wrong, IME\/CJK\/accent input misbehaves, right-click shows the wrong menu, window grows on reopen, or a Sparkle update fails. Symptom-to-mechanism triage table, the traps that cost real debugging time, discriminating trace checks, the repro escalation ladder, and the open-bug inventory so known bugs are not rediscovered fresh."
}

Edmund debugging playbook

Date-stamped 2026-07-05. Runbook for triaging any Edmund bug. Start at the triage table, run the discriminating first check before forming a theory, and check the open-bug inventory before declaring a discovery.

When NOT to use

  • Making a change, not chasing a bug → edmund-change-control.
  • You already know the bug is live-only and need to build a deterministic repro → edmund-live-repro-and-diagnostics (full ladder detail; summary in §5 here).
  • Caret-drift class specifically, with its six-round history → edmund-caret-integrity-campaign.
  • Build/toolchain/stale-binary mechanics beyond the quick checks here → edmund-build-and-env.
  • Release, signing, appcast, Sparkle pipeline → edmund-release-and-operate.
  • TextKit 2 / AppKit API semantics reference → textkit2-appkit-reference.
  • Launch flags and settings keys reference → edmund-config-and-flags.
  • How past investigations were run and why → edmund-failure-archaeology, edmund-research-methodology.
  • Pre-merge verification of a fix → edmund-validation-and-qa.

1. First 15 minutes — any new bug

Run this checklist before hypothesizing. Every step is cheap; skipping them is how rounds 1–5 of delete-drift shipped fixes that came back.

  1. Check the open-bug inventory (§6). If the symptom matches a known open bug, you are done triaging — link the backlog entry and its repro asset.
  2. Get the logs. ls -t ~/.edmund/logs/ and read the day's file (edmund-YYYY-MM-DD.log). Grep for the three permanent breadcrumbs:
    grep -n "healing storage edit that bypassed didChangeText\|repairing content above origin\|recovered stranded desync on focus regain" ~/.edmund/logs/edmund-*.log
    
    Also grep for invariant: (the always-on storage==rawSource tripwire) and ⚠︎LEN-MISMATCH.
  3. Find the first bad line and walk BACKWARDS. The user-visible symptom is often the second half of a two-part mechanism — the round-6 caret drift was armed by a silent bypass 80 seconds and dozens of healthy edits before the leap. Never start reading at the symptom timestamp.
  4. Rule out a stale build if this follows a rebuild: grep strings .build/arm64-apple-macosx/debug/edmd for a long string literal unique to the new code (≤15-byte literals are inlined on arm64 and never appear); shasum the binary. See §3c.
  5. Check git history for prior art. git log --oneline -- <suspect file> plus the investigation docs in docs/. The viewport-glitch investigation found four earlier fixes all working around the same unnamed root cause.
  6. Reconstruct the document. Get the user's file or rebuild it from the trace's block counts/lengths. Wrapped-paragraph geometry and block kinds matter; do not repro against "hello world".
  7. Before touching any running app: pgrep -lx edmd then ps -o lstart=,command= -p <pid>. The user's daily-driver app shares the binary name. Never blanket pkill -x edmd — kill only the PID you launched.
  8. Row found in §2 → run its discriminating check. No row → escalate per §5, and add the new row here when it's understood.

2. The triage table

Symptom Likely mechanism Discriminating first check Where next
Caret lands blocks away after delete or typing; text itself correct Delete-drift class: a storage edit bypassed didChangeText (drag-move to no valid target), or TextKit 2's queued _fixSelectionAfterChangeInCharacterRange fired at a later endEditing grep "healing storage edit that bypassed didChangeText" ~/.edmund/logs/edmund-*.log and read the trace around it; look for selectionDidChange with up=Y at a surprising position edmund-caret-integrity-campaign; docs/investigations/delete-drift-investigation.md
Every delete drifts, persistently, until an app switch fixes it Stranded IME composition: hasMarkedText() stuck true, didChangeText bails forever, model frozen Grep logs for recovered stranded desync on focus regain; check storage.string == rawSource docs/investigations/delete-drift-investigation.md rounds 1–2
Scroller jumps; scroll-to-target misses; content shifts on scroll TextKit 2 height estimates — off-screen frames are guesses corrected as layout reaches them Doc length vs fullLayoutMaxLength (100k UTF-16, EditorTextView.swift) — ≤100k should be fully laid out by the settle; >100k is estimate territory docs/investigations/viewport-glitch-investigation.md
First line unreachable above the top; scroller already at 0 TK2 strands fragments at negative y after a top-of-document edit grep "repairing content above origin" ~/.edmund/logs/edmund-*.log — present means the repair fired (diagnosis confirmed, repair maybe raced); absent means a different cause docs/investigations/viewport-glitch-investigation.md Bug 2 (repair unconfirmed live)
Undo/redo lands viewport in the wrong place; changed text not selected Regression of the diff-based restore contract (5bb2b40): a full recompose resets every fragment to an estimate, then the scroll measures the estimates Confirm restoreSnapshot still routes through range-bounded recomposeReplacing, never full recompose (+Undo.swift); check the changed range, not the stored caret, drives the viewport docs/investigations/viewport-glitch-investigation.md Bug 1
Code/visual change "doesn't take" after rebuild STALE BUILD — SwiftPM printed Build complete! without relinking edmd strings .build/arm64-apple-macosx/debug/edmd | grep "<long new literal>"; shasum before/after edmund-build-and-env; §3c
App crashes the instant any LaTeX renders SwiftMath *.bundle missing from the .app root (its Bundle.module is hardcoded to Bundle.main.bundleURL) ls build/Edmund.app/*.bundle scripts/build-app.sh copy step; ARCHITECTURE §8
Everything suddenly renders as plain text; all styling gone Silent, permanent TextKit 1 reversion: an NSLayoutManager API was touched or an NSTextBlock/NSTextTable attribute entered storage DEBUG builds assert via the tripwire (textKit1FallbackTripwire, willSwitchToNSLayoutManagerNotification); audit recent diffs for layoutManager / NSTextBlock ARCHITECTURE §2; textkit2-appkit-reference
Empty bands or clipped lines after a restyle Attribute-only change without invalidateLayout(for:) — TK2 keeps the stale fragment frame Is the misbehaving path a new styling path? recomposeDirty and the idle drain already invalidate; new paths must too ARCHITECTURE §8
Weird behavior only while composing CJK / accents / emoji A storage-touching styling path missing the !hasMarkedText() guard (including async work scheduled before composition began) Audit the new/changed path for the guard; check logs for a persisting LEN-MISMATCH during composition ARCHITECTURE §8; docs/investigations/delete-drift-investigation.md
Callout at end of file shows an extra colored line not prefixed by > KNOWN OPEN BUG — lives in the LIVE incremental restyle path only, not static rendering (a fresh full render is clean) Confirm against misc/bug-repros/callout-extra-line-rendered-at-bottom.mov misc/backlog.md
Image leaves a large blank space below it KNOWN OPEN BUG misc/bug-repros/image-blank-after.mov misc/backlog.md
Footnotes don't render (edit or read mode) KNOWN OPEN BUG misc/backlog.md
Right-click on the toolbar view-mode button shows "Customize Toolbar…" NSToolbar with allowsUserCustomization claims every secondary click over the toolbar, beating view-level handlers Verify the DocumentWindow.sendEvent(_:) intercept is intact (it swallows the click inside the button's bounds); note it does not cover true fullscreen ARCHITECTURE §8
Window grows by title-bar height on every reopen Frame-vs-content-size persistence trap: saving contentView.bounds.size and re-applying as contentRect Confirm lastWindowSize round-trips window.frame.size via setFrame after the toolbar is installed (Document.swift) ARCHITECTURE §8
Sparkle update fails: "The update is improperly signed and could not be validated" Bundle not sealed: signing only the main binary leaves no _CodeSignature/CodeResources; or the EdDSA keypair diverged from SUPublicEDKey Does build-app.sh still seal the whole .app before copying the SwiftMath bundle in? edmund-release-and-operate; ARCHITECTURE §8/§13
Callout/overlay icon wedges a wrapping line down to one line TK2 image-on-multiline-fragment wedge: drawing an image on a wrapping fragment collapses its layout; shapes do not Is the overlay an NSImage on a fragment that can wrap? Convert to a stroked CGPath (the custom-title callout icon fix) docs/investigations/archives/callout-title-wrap-investigation.md
Dragging produces no visible selection at all Not a bug: a selection (possibly whole-document) was already active, and dragging inside an existing selection is AppKit's drag-move gesture Trace: was there a selectionDidChange with a large sel before the drag began? docs/investigations/viewport-glitch-investigation.md Bug 3 phase 1
Viewport oscillates up/down during a steady drag-select Two scroll policies fighting: drag autoscroll vs a reveal that follows the wrong end of a taller-than-viewport selection Confirm the scrollRangeToVisible override still reveals the selection's nearest end (+TypewriterScroll.swift, commit 340fcbc) docs/investigations/viewport-glitch-investigation.md Bug 3 phase 2
Edits do nothing at all (not drift — dropped) isUpdating stuck true would make shouldChangeText return false Trace shows shouldChangeText never returning OK; distinct from the drift signature +EditFlow.swift
Launching via open shows old behavior LaunchServices foregrounded a running instance, or ran a stale cached/translocated copy pgrep -lx edmd first; launch by direct exec of the bundle binary §3e; edmund-build-and-env

3. The traps that cost real time

Each of these burned hours to days. Read before shipping any fix.

(a) Shipping caret fixes on reasoning alone. Delete-drift rounds 1–5 each shipped a plausible, well-argued fix — and each came back. Only round 6, the first with a frozen deterministic live repro (bypassdelete script), named the actual mechanism (_fixSelectionAfterChangeInCharacterRange queued by a bypassed edit, firing at the next endEditing) — and its first fix attempt failed in the repro within a minute, which reasoning would never have caught. Lesson: time spent making the failure cheap to observe beats time spent reasoning about the fix. Freeze the repro before writing the fix.

(b) Undo/redo viewport drift. Two defects hid behind one symptom: restoreSnapshot ran a full recompose (discarding all TK2 layout, so the follow-up scroll measured freshly manufactured estimates) and performUndo recorded the redo snapshot with the caret at undo-invocation time, not at the edit. Lesson: a wrong-scroll symptom can be a geometry bug and a plain stale-state bug stacked; fix and verify them separately. The contract since 5bb2b40: diff the snapshot, apply via recomposeReplacing, select the changed text, let the changed range drive the viewport.

(c) Stale binaries produce false conclusions. In round 6, swift build twice printed Build complete! while linking a stale edmd — the compile ran, the relink silently didn't — and two "failed" fix iterations were phantoms. Detect: grep strings on the binary for a long literal unique to the new code. Cure: swift package clean (or rm -rf .build for release weirdness). Never hand-delete .build/…/edmd.build/ — that corrupts the output-file-map and wedges the target until a full clean.

(d) Headless-green ≠ fixed for input-layer bugs. The round-6 unit test reconstructs the exact document and gesture and passes with and without the fix: the test harness runs AppKit's deferred selection fixup synchronously, so the broken state never forms. A green test proves nothing about the live NSTextView / TextKit 2 / input-context class. The scripted live repro is the regression harness; the unit test is only a contract spec.

(e) open -a runs stale cached copies. LaunchServices can foreground an already-running instance instead of relaunching, and can execute a stale cached/translocated copy of the bundle — you debug last hour's code. Always launch by direct exec: build/Edmund.app/Contents/MacOS/edmd file.md & (after the §1 step-7 pgrep check).

(f) Trusting off-screen fragment y-coordinates. A TK2 fragment's frame is real only once laid out; everything off-screen, plus total document height, is an estimate. Any code that measures before ensuring layout of the viewport↔target span lands wrong (this is Bug 1a, the typewriter-scroll gotcha, and most historical viewport glitches). Ensure layout for the target range first, then align to real geometry — and never verify a visual fix from headless layout: measure from screencapture pixels.

4. Discriminating experiments — cheap checks that split hypothesis spaces

Verbose diagnostics launch (defaults keys are namespaced; the file must be argv[1]):

build/Edmund.app/Contents/MacOS/edmd FILE.md \
  -settings.general.diagnosticLogging YES \
  -settings.advanced.verboseEditorDiagnostics YES

Or toggle in Settings ▸ Advanced ("Save diagnostic logs" + "Verbose editor tracing"). Logs land in ~/.edmund/logs/edmund-YYYY-MM-DD.log.

Trace field vocabulary (source of truth: Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift, diagnosticState). Every trace line ends with:

Field Meaning
sel={loc,len} current selection
active= active block index (or nil)
marked= marked-text range, - if none (non-- outside a live composition = stranded)
up=Y/N isUpdatingY means the event arrived MID-RECOMPOSE
undo=Y/N isUndoRedoing
blocks= block count
storLen= / rawLen= storage vs rawSource lengths
⚠︎LEN-MISMATCH appended when they differ

Healthy vs suspect edit orderings:

  • Healthy: shouldChangeTextselectionDidChange (up=N) → synced. A transient ⚠︎LEN-MISMATCH between those lines is normal (storage moves before rawSource syncs).
  • Suspect: a selectionDidChange with up=Y at a surprising position; a persisting LEN-MISMATCH; a shouldChangeText with no synced/SKIPPED/DEFERRED line after it (bypassed didChangeText); the healing storage edit that bypassed didChangeText breadcrumb.

traceSelectionOrigin: under verbose tracing, any selection change arriving mid-recompose (up=Y) or with an unconsumed pendingEdit logs a condensed call stack naming the AppKit path that moved the caret. This is what named _fixSelectionAfterChangeInCharacterRange in round 6. If your bug moves the caret and you don't know who moved it, this answers it in one run.

Walk backwards from the first bad line, not forwards from the symptom. The round-6 drift was armed 80 seconds before the visible leap. Find the first line whose state is wrong, then read earlier.

verifyEditorInvariants (same file): the O(1) length check (storage.length != rawSource.length) logs an error whenever logging is on — no verbose toggle needed — so a hard-invariant break always leaves an invariant: line. The full structural checks (string equality, blocks reconstruct rawSource, block ranges in bounds) run under verbose tracing and assert in DEBUG. An invariant: error in a user's log is a model desync, full stop; triage as the delete-drift class.

If the existing logging didn't capture the deciding fact, add the log line first and reproduce again — one breadcrumb beats ten speculative fixes. Keep good ones behind Log.shouldTrace and ship them.

5. The escalation ladder (summary)

Full detail, ReproScript command reference, CGEvent driver, and soak-script method: edmund-live-repro-and-diagnostics and docs/dev-guides/live-repro-guide.md. Work down; stop at the first level that reproduces.

  1. Plain unit test (makeEditor()) — model/parsing/styling logic.
  2. Windowed unit test (NSWindow + NSScrollView, real deleteBackward) — adds layout, viewport, first-responder. Failure to repro here is evidence, not defeat: it points at deferred/queued AppKit state and at levels 3–4.
  3. In-process ReproScript — DEBUG builds accept -debug.reproScript <path> (Sources/edmd/App/ReproScript.swift); replays a keystroke script through the real window.sendEvent path. No Accessibility/TCC needed, works with the window on an invisible Space. Commands: sleep, caret, type, backspace, bypassdelete, assertcaret, logsel. Launch with -ApplePersistenceIgnoreState YES and recreate the test document fresh each run. The default for live bugs.
  4. CGEvent driver — only for paths that must originate as HID events (drag-select, drag-move, autoscroll). TCC decides per session; if input doesn't land after one test click, fall back to level 3 immediately.
  5. Instrumented field occurrence — can't trigger it yourself: add the decisive breadcrumb, ask the user to enable verbose tracing, and wait. Days of latency; make sure the next occurrence is decisive.

After a fix: freeze the repro script, run a soak (several trigger cycles in one app run with assertcaret checks), then full swift test.

6. Open-bug inventory

Known open bugs — check here before "discovering" one. Sources: misc/backlog.md (authoritative list) and docs/ROADMAP.md (larger themes, e.g. "TextKit 2 viewport stabilization" is a v1.0.0 item — viewport estimate glitches are a known, partially-mitigated class). All entries below are OPEN as of 2026-07-05.

Bug Status Repro asset
Delete caret drift (class) Open as a class; rounds 1–6 fixed, watching for round 7 misc/bug-repros/delete-caret-drift-{1.mp4,2.mov,3.mov,4.mov} + matching logs
Inaccurate viewport estimates & related Open class; small-doc mitigations shipped, Bug-2 repair unconfirmed live
Callout as last element renders an extra colored line Open; live incremental restyle path only, NOT static rendering misc/bug-repros/callout-extra-line-rendered-at-bottom.mov
Image creates large empty space below Open misc/bug-repros/image-blank-after.mov
Footnotes don't render (edit or read mode) Open
Math environments don't render in read mode Open misc/bug-repros/math-baseline-read-mode-png.png (related baseline issue)
Math environments have wrong padding in edit mode Open
Max content width not applied to read mode Open
Images should shrink when content size is small Open
Tables should wrap when content size is small Open
Table cell content wraps out of the cell Open
Click-to-select / select+delete sometimes doesn't work Open, intermittent
Scroll glitch from height changes outside viewport Open, unreproduced ("Lurking" in backlog)
Cursor stuck at indented position after indented editing Open, unreproduced; awaiting screen recording
Undo/Redo and Copy/Paste scrolling "failing again" Open, unreproduced (post-fix recurrence report)

Do not relabel any of these as fixed without a verified repro flip; no oversell. When you fix one, update misc/backlog.md and this table in the same branch.

7. House rules while debugging

  • Never blanket pkill -x edmd. The user's daily-driver app shares the binary name. pgrep -lx edmd + ps -o lstart=,command= -p <pid>, then kill only your own PID (pkill -f EdmundDbg if you launched the debug bundle).
  • Do not request Computer Access — Screen Recording and Accessibility are already granted.
  • Measure visuals from screencapture pixels (capture by window id, see ARCHITECTURE §8), never from headless layout alone.
  • Never mutate storage while hasMarkedText() — including in any diagnostic or repro code you add.
  • Never auto-push, PR, or merge. Branch off main per fix; commit small and often.
  • Logs in ~/.edmund/logs are app-owned and fair game to read and quote.

Provenance and maintenance

Built 2026-07-05 from: docs/ARCHITECTURE.md (§2, §8, §9), docs/investigations/delete-drift-investigation.md (rounds 1–6), docs/investigations/viewport-glitch-investigation.md, docs/investigations/archives/callout-title-wrap-investigation.md, docs/dev-guides/live-repro-guide.md, misc/backlog.md, docs/ROADMAP.md, and Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift. Log strings (healing storage edit that bypassed didChangeText, repairing content above origin, recovered stranded desync on focus regain), launch-flag keys, fullLayoutMaxLength, ReproScript commands, the TK1 tripwire, and commit 5bb2b40 were verified against the source tree on that date.

Maintain it like the codebase docs: when a new bug class is understood, add its triage row; when a trap costs real time, add its story to §3; when a backlog bug opens or closes, sync §6 with misc/backlog.md in the same branch. If a row's discriminating check stops matching the code (renamed log string, moved file), fix the row — a stale runbook is worse than none. Deeper mechanism detail belongs in the sibling skills and docs/ investigation files, not here; this file stays a triage surface.

用于规范 Edmund 项目文档编写与维护,明确各事实的唯一归属地、写作风格及路由规则。适用于撰写或更新架构、变更日志、README 等文档,以及决定新信息存放位置,不处理代码实现或调试。
撰写或更新项目文档 确定新发现的事实或 bug 的存储位置
.claude/skills/edmund-docs-and-writing/SKILL.md
npx skills add I7T5/Edmund --skill edmund-docs-and-writing -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-docs-and-writing",
    "description": "Documentation of record for the Edmund repo: which doc owns which fact, and how to write in the house style. Load whenever you are writing or updating ANY project doc — docs\/ARCHITECTURE.md, CHANGELOG.md, README.md, docs\/ROADMAP.md, misc\/backlog.md, a docs\/<topic>-investigation.md write-up, release docs — or deciding WHERE a newly learned fact, gotcha, bug, or feature idea belongs. Covers the docs-of-record map, the fact-routing decision table, the investigation-doc template, CHANGELOG format (machine-extracted for release notes), commit-message conventions, and doc maintenance duties. Not for making the code change itself, release mechanics, or debugging — see \"When NOT to use this skill\"."
}

Edmund docs and writing

Date-stamped 2026-07-09. Every claim below was verified against the files on main at that date; re-verify paths before trusting this after major reorganizations.

When NOT to use this skill

You are actually doing Use instead
Changing code / designing a mechanism edmund-architecture-contract
Branch/commit/PR mechanics, pre-commit checklist edmund-change-control
Cutting a release, appcast, Sparkle, CI edmund-release-and-operate
Diagnosing a bug (not writing it up) edmund-debugging-playbook, edmund-live-repro-and-diagnostics
Mining past investigations for technique edmund-failure-archaeology
Marketing copy, positioning, alternatives research edmund-external-positioning
Build flags, env, debug bundle edmund-build-and-env, edmund-config-and-flags

This skill is for prose: what to write, where it lives, how it should read.

1. The docs-of-record map — one home per fact

Every fact has exactly one home; everywhere else gets a pointer. All paths exist and are current as of 2026-07-09.

Doc Owns Notes
docs/ARCHITECTURE.md HOW the system works: build/test commands (§1), the two invariants (§2), render pipeline (§3), edit/undo flow (§4), TextKit 2 drawing (§5), feature map (§6), settings (§7), gotchas (§8), known issues (§9), code debt (§10), agent quick start (§11), working agreements (§12), release/CI (§13), references (§14) THE agent-onboarding doc. Its own header states the rule: when you learn something non-obvious or change an invariant, edit this file in the same PR.
docs/architecture/README.md Human developer overview: what Edmund is, the two invariants (summarized, not owned), a map of docs/architecture/'s deep docs and the sibling investigations//dev-guides/ folders, common quirks (each a pointer, never a new claim), getting-started commands The human entry point ARCHITECTURE.md's header note links to. Every fact here traces to ARCHITECTURE.md or a deep doc — this file summarizes, never owns.
docs/architecture/<topic>.md Deep narrative write-up of one subsystem (e.g. editor-pipeline.md, text-system.md) The "deep-doc" pattern: a fact's statement lives in ARCHITECTURE.md, its explanation lives here, each links to the other.
docs/architecture/extensibility.md The design-of-record for themes/extensions: vision, current state (verified against main and the unmerged feat/extensions-registry-and-tab branch), themes/extensions design, staged implementation plan, honest risks Design only, not yet implemented on main. ARCHITECTURE.md gets no extensibility section until code lands (same-PR rule) — this doc is the exception to the deep-doc pattern above: there is no ARCHITECTURE.md statement to expand yet.
docs/architecture/sandboxing.md The App Sandbox preparation plan: CotEditor reference model, touchpoint-to-fix inventory, entitlements/build-variant mechanics, the ~/.edmund/ onboarding grant, staged plan (SB0-SB4), open decisions Plan only, nothing sandboxed on main. Same design-doc exception as extensibility.md: no ARCHITECTURE.md statement exists yet; when a stage lands, its facts move to ARCHITECTURE.md in the same PR.
README.md WHAT/WHY for users: differentiators, screenshots, install (incl. the Gatekeeper "DAMAGED" xattr -dr com.apple.quarantine workaround), dependencies, alternatives, acknowledgements, license User-facing; no internals.
CHANGELOG.md User-facing version history, Keep-a-Changelog style ## [x.y.z] sections are machine-extracted for release notes — exact format matters (§4 below).
docs/ROADMAP.md Versioned feature plan: ## v1.0.0, ## v1.x, # v.2.0.0 sections of checkbox lists, grouped by theme (editing, extensions, macOS integrations) Has a Last updated: YYYY-MM-DD line under the title — refresh it when you edit.
misc/backlog.md The maintainer's working priority list: ## Now (small releases) (Marketing / On-going bugs / Bugs / UI/UX / Features), ## Next, ## Later, roadmap mirrors, ### Lurking (Unreproduceable), ## Done Stated priority: Marketing = Bugs >= UI/UX > Features. Bug entries carry repro pointers (misc/bug-repros/*.mov, .log, or ~/Desktop paths).
docs/investigations/<topic>-investigation.md Deep multi-round investigation chronicles for active bug classes Existing: delete-drift-, viewport-glitch-investigation.md. Template in §5.
docs/investigations/archives/<topic>-investigation.md Chronicles for closed/resolved bug classes Existing: callout-bottom-line-, callout-title-wrap-investigation.md.
docs/dev-guides/live-repro-guide.md Method doc: the escalation ladder for reproducing live-app bugs Referenced from ARCHITECTURE §11.
misc/before-you-release.md Pre-flight readiness checklist Pairs with how-to-release.md; cross-ref edmund-release-and-operate.
misc/how-to-release.md Release mechanics (CI tag path, local release.sh) Same.
CLAUDE.md (root) Behavior contract for agents: env, git practices, pre-commit checklist, the comment-at-the-code rule Short by design; it delegates the "how" to ARCHITECTURE.
LICENSES/ Vendored license texts (currently lucide.txt for the Lucide icon SVGs) Add one when vendoring third-party assets.
Info.plist CFBundleShortVersionString + CFBundleVersion — the version of record Must match the CHANGELOG section header at release (see misc/before-you-release.md §3).

Note: misc/backlog.md and docs/ROADMAP.md currently duplicate the v1.0.0/v1.x/v2.0.0 sections (backlog carries an extended copy). ROADMAP is the public plan; backlog is the working list. When they disagree, treat ROADMAP as the versioned commitment and backlog as scratch — and mention the drift to the maintainer rather than silently reconciling.

2. Where does a new fact go — decision table

Route the fact FIRST, then write. One home; cross-reference from elsewhere.

You learned / produced Home How
Code quirk, edge case, workaround, non-obvious why Comment at the code site House rule (root CLAUDE.md): "Document non-obvious behavior... as a short comment at the code itself — not in commits or this file."
Architectural gotcha that will bite the next agent ARCHITECTURE.md §8 Bold lead-in bullet + one-line repro/symptom + pointer to any deeper write-up. Same PR as the code change.
New known issue / structural constraint ARCHITECTURE.md §9 It has an explicit placeholder: "Add new ones here as you find them — with a one-line repro and a pointer to any deeper write-up in docs/."
Code debt / incomplete implementation ARCHITECTURE.md §10 Its footer says: track code-debt here, roadmap items in README/ROADMAP.
Changed invariant, new subsystem, new pipeline step ARCHITECTURE.md §2–§7 (the relevant section) Update in the same PR — header rule.
Multi-round investigation (2+ hypothesis cycles, live repro work) New docs/<topic>-investigation.md Use the §5 template. ALSO add a one-bullet §8 gotcha summarizing the rule it produced, pointing at the doc.
User-visible change (fix/feature/rename) CHANGELOG.md under the next ## [x.y.z] Format in §4. Link the issue and any investigation doc.
New bug found (reproducible) misc/backlog.md under Bugs - [ ] Bug: <symptom>. See <repro pointer>. Drop repro assets (video/log) into misc/bug-repros/.
New bug found (unreproducible so far) misc/backlog.md### Lurking (Unreproduceable) One line + "wait for screen record" style note.
Bug that is really code debt (design limitation) ARCHITECTURE.md §9 e.g. the image-on-wrapping-fragment constraint.
Feature idea, near-term (next few small releases) misc/backlog.md (Now/Next/Later) Sorted by priority + difficulty within category.
Feature idea, versioned/strategic docs/ROADMAP.md under the right version Refresh Last updated.
Repro method / debugging technique docs/dev-guides/live-repro-guide.md Method docs, not per-bug chronicles.
Release procedure change misc/how-to-release.md / misc/before-you-release.md + ARCHITECTURE.md §13 §13 owns the mechanism + failure modes; misc/ owns the operator checklist.
Agent workflow improvement ARCHITECTURE.md §12 Its footer invites this: "If you (the agent) improve this workflow... update this section."
Vendored third-party asset LICENSES/<name>.txt + a feature-map note in §6 Follow the Lucide precedent.
Deep explanation of an existing subsystem docs/architecture/<topic>.md A fact's statement lives in ARCHITECTURE.md; its explanation lives in the deep doc; each links to the other.

The same-PR rule is the load-bearing one. Doc updates that ride the code PR actually happen (see cf10741, b600e12, c4a602b in history); doc updates deferred to "later" don't.

3. House style

Derived from reading ARCHITECTURE.md and the investigation docs. Match it.

  • Dense, specific, evidence-first. State the mechanism and the proof, not vibes. "Verified against that exact API" (§8 Sparkle bullet), timestamps and selection ranges quoted verbatim in investigation docs.
  • Bold lead-ins for gotcha bullets, then the explanation: - **Stale release builds**: .... Scannable list, detail inline.
  • Backticks for every file, symbol, flag, and command: `recomposeDirty`, `+EditFlow` (the extension-file shorthand), `-debug.reproScript`.
  • One-line repro pointers, not embedded essays: "See misc/bug-repros/image-blank-after.mov", "grep ~/.edmund/logs for repairing content above origin".
  • Honest status labels. The docs say "unconfirmed live", "theory + targeted repair, not a confirmed kill", "Verification limits (honest gaps)", "the test documents intent; the leap only reproduces under live layout". Never claim verification you didn't do. No oversell.
  • Section anchors as cross-refs: "see §8", "ARCHITECTURE §13" — used across ARCHITECTURE, CLAUDE.md, before-you-release.md. If you renumber sections, grep the repo for § and fix every reference.
  • Address "you", the next agent/engineer: "will bite you", "Context for anyone who sees the bug again", "Next time it happens: ...".
  • Record what failed, not just what worked — investigation docs keep the overturned theories and the phantom fixes (stale-binary trap) because the dead ends are the reusable knowledge.

Commit messages (from git log --oneline -50)

Mixed but patterned: conventional prefixes dominate for fixes and docs — fix(scope): ... (scopes seen: editor, layout, scroll, undo, release-workflow, changelog-to-html), docs: ..., occasional refactor:, appcast: add Edmund X.Y.Z, release X.Y.Z. Chores and README work often use plain imperative subjects ("Update README", "Add assets for README"). Branches: fix/<slug>, docs/<slug>, chore/<slug>. When in doubt: fix(scope): for behavior changes, docs: for doc-only commits, plain imperative for chores. Never auto-push, PR, or merge — only when asked.

4. CHANGELOG format — machine-read, get it exact

.github/workflows/release.yml extracts release notes with:

awk "BEGIN{p=0} /^## \[${VERSION}\]/{p=1;next} p && /^## \[/{exit} p{print}"

So the section header MUST be ## [x.y.z] at line start, version matching CFBundleShortVersionString exactly; the section ends at the next ## [. scripts/changelog-to-html.py converts the same section to HTML for Sparkle's update dialog (it folds wrapped bullet lines into their <li> — wrapping bullets is safe). Full pipeline: edmund-release-and-operate.

House format (verify against the file; current entries follow this):

## [0.1.4] — 2026-07-XX

### Fixed
- <User-facing symptom, past tense optional> ([docs](docs/<topic>-investigation.md)) [#NNN](https://github.com/I7T5/Edmund/issues/NNN)

---
  • Em dash between version and ISO date; --- separator between versions.
  • Subsections used so far: ### Added, ### Changed, ### Fixed (Keep a Changelog 1.1.0 vocabulary).
  • Entries describe the user-visible effect, not the mechanism; mechanism lives in the linked investigation doc / ARCHITECTURE.
  • An optional free-text line under the header is fine (0.1.2 has one) — the awk extraction includes it.

5. The investigation-doc template

Derived from docs/investigations/delete-drift-investigation.md (6 rounds) and docs/investigations/viewport-glitch-investigation.md. Both open with why the doc exists ("Context for anyone who sees the bug again... records the trail end to end") and name the fixing commits/branch up front. Chronicle structure: each recurrence is a new ## Round N appended to the same doc — symptom → diagnosis → root cause → fix → verification, with limits stated.

Skeleton (copy-paste):

# <Area> "<bug nickname>" — investigation notes

Context for anyone who sees this again. <One line on why it was hard:
intermittent / state-dependent / looked nothing like its cause.>

Fixed on branch `fix/<slug>`, commits: `<sha>` — <subject>, ...

## Symptom

<Exact user-visible behavior. Bulleted key properties, each a discriminating
fact ("caret-only, text fine"; "never right after launch"). Evidence
pointers: `misc/bug-repros/<file>`, `~/.edmund/logs/...`.>

## How it was diagnosed

1. <Numbered steps in the order they happened, including overturned
   theories and WHY each clue narrowed the space.>

## Root cause

<The mechanism, in bold where it matters. Explain why every symptom
property follows from it.>

## The fix

<What changed, in which file, and why that shape (defenses tried and
rejected count too).>

## Verification

<Tests added, live repro results, suite count. Then an honest limits
subsection: what was NOT reproduced/confirmed, and the breadcrumb to grep
for if it recurs.>

## If it ever recurs

<Ordered checks for the next investigator: which invariant/log/flag to
inspect first.>

## Round 2: <one-line summary>   ← append on recurrence, same structure

After writing one: add the one-bullet gotcha to ARCHITECTURE §8 with a pointer, add the CHANGELOG entry with a ([docs](docs/...)) link, and check the corresponding misc/backlog.md box (or move it under On-going bugs).

6. Maintenance duties

Do these whenever you touch the relevant doc; they rot otherwise.

  • ARCHITECTURE placeholders: §9 and §10 end with italic "Add new ones here" / "track code-debt here" lines — keep them last in their lists so the invitation stays visible.
  • ROADMAP Last updated: — bump the date on any edit.
  • Backlog hygiene: check - [x] boxes when a fix ships (move to ## Done only if following the existing pattern — completed items live there); keep repro pointers valid; don't reorder the maintainer's priority sorting.
  • README's inline HTML comments are the maintainer's own edit notes (e.g. <!-- Replace "minimal" with ... -->) — leave them unless acting on them.
  • At release: CHANGELOG section header ↔ Info.plist version ↔ appcast <item> must agree; the checklist is misc/before-you-release.md, the mechanics edmund-release-and-operate.
  • Section renumbering in ARCHITECTURE: grep the whole repo (docs, misc, CLAUDE.md, skills) for § references before and after.
  • Never edit test-files/todo.md — the maintainer owns it.

Provenance and maintenance

Written 2026-07-05 against main at fe8a1f5 (release 0.1.3). Sources, all read directly: docs/ARCHITECTURE.md (header, §8–§13), README.md, CHANGELOG.md, docs/ROADMAP.md, misc/backlog.md, docs/investigations/delete-drift-investigation.md, docs/investigations/viewport-glitch-investigation.md, docs/dev-guides/live-repro-guide.md (§1), misc/before-you-release.md, misc/how-to-release.md, root CLAUDE.md, .github/workflows/release.yml (awk extraction quoted verbatim), git log --oneline -50 (commit-style tally), directory listings of docs/ (architecture/, investigations/ incl. archives/, dev-guides/), misc/, misc/bug-repros/, LICENSES/.

§1 map re-verified 2026-07-09 against the docs/ reorg (investigation docs split into docs/investigations/ + docs/investigations/archives/; docs/live-repro-guide.md moved to docs/dev-guides/).

Maintain this skill when: a doc of record moves or splits (update the §1 map), ARCHITECTURE sections are renumbered (fix every § reference here), the CHANGELOG extraction in release.yml changes (§4 quotes it), or a new investigation doc establishes a better template. Keep the one-home-per-fact rule itself stable — it is the point of the skill.

防止对外宣传过度承诺。规范README、博客等公开内容的措辞,确保所有声明均可由仓库复现,严格区分已验证与候选功能,禁止在Beta阶段使用“新颖”等未证实标签,维护诚实的产品定位。
撰写面向外部的README、博客、发布说明或营销文案 将Edmund与其他编辑器(如Typora、Obsidian)进行对比 决定哪些内容可公开声称,哪些仍属未验证状态 标记某项技术为“新颖”或规划生态系统工作
.claude/skills/edmund-external-positioning/SKILL.md
npx skills add I7T5/Edmund --skill edmund-external-positioning -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-external-positioning",
    "description": "Load when writing anything an outsider will read about Edmund — README edits, blog posts, release notes, marketing copy, social posts, webpage text, Show HN drafts — or when comparing Edmund to other editors (Typora, Obsidian, MarkEdit, Nodes), deciding what may be publicly claimed vs. what is still unproven, labeling a technique \"novel\", or planning ecosystem work (licenses, attribution, notarization messaging, appcast, issue templates, discovery listings). This skill is the overclaim firewall: what the positioning is, what evidence backs each claim, and what must exist before a claim gets stronger."
}

Edmund external positioning — what we claim, what we can prove

Governing rule: nothing may be claimed publicly that an outsider cannot reproduce from the repo + a release. Unproven = "candidate", never "novel" or "first". This skill exists to prevent overselling a beta.

When NOT to use this skill

You are doing Use instead
Actually cutting a release (tags, appcast, Sparkle, CI) edmund-release-and-operate
Internal docs, ARCHITECTURE.md, investigation chronicles edmund-docs-and-writing
Understanding the invariants / render pipeline itself edmund-architecture-contract
Deciding whether a code change is allowed edmund-change-control
Reproducing or debugging a bug edmund-debugging-playbook, edmund-live-repro-and-diagnostics
Judging research novelty for internal direction (not public claims) edmund-research-frontier, edmund-research-methodology
Verifying behavior before shipping edmund-validation-and-qa

1. The positioning (quote it exactly)

Source of truth: README.md. As of 2026-07-05:

  • One-liner: "Edmund is a minimal, file-based, native Markdown editor for macOS with inline live preview."
    • README carries its own TODO comment on this line: "Replace 'minimal' with 'customizable' or 'lightweight' once more features are implemented" — do not do that replacement early; "minimal" is the honest word today.
  • Goal statement: "Our goal is to be the CotEditor of Markdown editors, i.e. elegant, powerful, configurable, and native inside out."
  • Beta warning: "⚠️ Edmund is currently in beta." — this must stay visible in the README and any landing page until v1.0.0 ships.
  • Maintainer's blog post: https://i7t5.com/posts/2026-06-26-edmund/ ("more of the motivation and design philosophy"). Cite it; do not paraphrase or invent its content without fetching it.
  • Ambition framing: product-first. "Beyond state of the art" means product excellence — the TextKit 2 techniques are means, not ends. Never lead public copy with internal mechanism names; lead with what the user gets.

The six claimed differentiators (README, verbatim, 2026-07-05)

# Claim (verbatim)
1 "Live preview: Typora/Obsidian-style WYSIWYG."
2 "File-based: Open .md files from anywhere. No vaults or dedicated folders required."
3 "Native: 100% Swift. Based on AppKit and TextKit 2. No Electron. Minimal dependencies."
4 "Fast: Handles ~1-2MB files with ease. No launch lag."
5 "Extensible: Opt-in math and Obsidian syntax. Extensions system coming soon!"
6 "Private: Offline by default. Optional blocking of external links and HTML sanitization."

README also has a TODO comment after the list: "Move 'Fast' and 'Extensible'? Add 'integrations' section to Native after implementation" — the list is known-provisional; keep quotes synced to the file when you edit copy.

2. Claims discipline

Before strengthening any claim publicly, the evidence in the middle column must be upgraded to the right column. Status as of 2026-07-05.

Claim Current evidence Required before strengthening
Fast: "~1-2MB files with ease. No launch lag" Tests/EdmundTests/PerfHarnessTests.swift (gated MD_PERF=1, default 1.5MB doc, prints latencies; assertions are deliberately "sanity bounds, not budgets"); viewport-first lazy styling with fullLayoutMaxLength = 100_000 regime (EditorTextView.swift:80) A reproducible public benchmark: pinned document + hardware noted + numbers an outsider can rerun (MD_PERF=1 swift test). No comparative "faster than X" claims without benchmarking X the same way.
Extensible: "Extensions system coming soon!" Extensions API is docs/ROADMAP.md v1.0.0 — not shipped. Only opt-in math + Obsidian syntax exist today. README already hedges with "coming soon" Keep it hedged until the API + docs + at least one working extension ship. Never write "extensible via plugins" in present tense.
Private: "Offline by default" Grounded: Read webview disables JavaScript; all assets inlined as data URIs (math, icons, local images); remote images off by default (ReadRenderOptions.allowRemoteImages = false); inline HTML whitelisted via HTMLRenderer.sanitizeInlineHTML Note: the "Block external images" Settings checkbox is a misc/backlog.md item, not shipped; "optional blocking of external links" in README is forward-leaning — verify against code before repeating it elsewhere. Exceptions to name if asked: Sparkle update check, opt-in crash-log upload (§7 ARCHITECTURE).
Native: "100% Swift … No Electron. Minimal dependencies" True: SwiftPM, three deps (swift-markdown, SwiftMath, Sparkle) + vendored Lucide SVGs. Read mode is a WKWebView (system WebKit, JS off) — that is not Electron, but don't say "no web views" Nothing; this claim is safe. Just never inflate to "zero dependencies".
Live preview: "Typora/Obsidian-style" Shipped and demoed (README video, screenshots) Safe. Comparative feature-parity claims vs. Typora/Obsidian need a feature-by-feature check first.
File-based: "No vaults" Shipped by design Safe.
Beta status v0.1.3 (2026-07-04) Must stay visible everywhere until v1.0.0.

3. Novel vs. known — the honest inventory

When writing a craft blog post or comparison, keep this ledger straight. "Candidate" means blog-worthy pending proof; it is not "proven novel".

Known / prior art (never claim novelty here)

  • Live-preview Markdown editing: Typora, Obsidian, MarkText, Nodes. MarkEdit is the stated reference for source mode (ROADMAP v2.0.0 "the MarkEdit experience").
  • TextKit 2 viewport virtualization for Markdown: nodes-app/swift-markdown-engine (Apache 2.0, macOS 14+) solves the same problems — viewport virtualization, live styling, wiki links, reading column, LaTeX. Per ARCHITECTURE §14: consult it before inventing a new mechanism and as a technique source (drag-select autoscroll, overscroll). Its existence caps any "first TK2 live-preview engine" claim at zero.
  • The README's own Alternatives section credits ~15 editors. Public copy that ignores them reads as either ignorant or dishonest.

Distinctive candidates (label as such; each needs proof before publishing)

Candidate Why it might be blog-worthy Proof needed first
Attribute-only rendering with the storage == rawSource invariant (no attachment characters, no U+FFFC; delimiters hidden, never stripped; identity offset mapping) A clean architectural answer to the classic WYSIWYG mapping problem A survey showing how the named alternatives (incl. swift-markdown-engine) handle storage vs. display; the invariant's consequences demonstrated with runnable examples
Stroked-CGPath overlay workaround for the TK2 image wedge (image in a fragment overlay collapses the fragment's layout to one line; callout icon drawn as stroked path from vendored Lucide SVG instead) A concrete, reproducible TK2 bug + workaround — the classic useful engineering post A minimal frozen repro of the wedge outside Edmund; macOS version range where it reproduces
Bypassed-didChangeText heal + caret re-assertion (round-6 mechanism: TK2 leaves a _fixSelectionAfterChange queued after a bypassed edit; next-run-loop sync check heals storage and re-asserts the caret) Deep TK2 internals nobody has documented; the delete-drift chronicle (docs/investigations/delete-drift-investigation.md) already exists as raw material The frozen ReproScript repro kept green; behavior confirmed on current macOS before publishing (private-method behavior can change under us)
Diff-based undo restore that preserves TK2 layout (snapshot restore diffs rather than replaces, bypassing NSTextView undo) Practical fix for a visible TK2 pain (undo viewport yank) Before/after measurements (layout work saved, viewport stability) on a pinned document
In-process ReproScript methodology (-debug.reproScript, keystroke replay without CGEvents/TCC; docs/dev-guides/live-repro-guide.md) Reusable testing methodology for any AppKit text app Show it reproducing a real bug end-to-end in a fresh checkout; that IS the reproducibility standard

Reproducibility standard for any technical post: a reader with the repo and the post must be able to reproduce every claim — frozen repro scripts, pinned document fixtures, named macOS versions, measured numbers with the command that produced them. If a claim can't meet that, cut it or mark it anecdotal.

4. Ecosystem and license hygiene

Verified against the repo, 2026-07-05:

  • License: Apache 2.0 (LICENSE, README "License" section, and the 0.1.0 changelog entry all agree). Say "Apache 2.0", never "MIT".
  • Lucide icons: vendored, ISC (LICENSES/lucide.txt, © 2026 Lucide Icons and Contributors; parts derived from Feather). Attribution duty: keep LICENSES/lucide.txt shipping and credit Lucide where icons are discussed.
  • Why Lucide in both modes (SF Symbols constraint): ARCHITECTURE §6 — SF Symbols cannot ship in exported PDFs (license), so callout headers use Lucide in both Read (inline SVG, currentColor-tinted) and Edit (rasterized tinted NSImage overlay). App-chrome SF Symbols (toolbar/settings) are fine; Edit-mode task checkboxes still use SF Symbols on-screen only. Don't "simplify" copy or code in a way that breaks this split.
  • Dependencies to credit: swift-markdown, SwiftMath, Sparkle (README "Dependencies"). Acknowledgements section additionally credits swift-markdown-engine/Nodes, Typora, theme sources, create-dmg, MarkEdit, and others — preserve it when restructuring the README.
  • Not notarized (2026-07-05): ad-hoc signed; users hit Gatekeeper ("damaged app"). README's WARNING block gives the two workarounds (System Settings → Privacy & Security → Open Anyway; or xattr -dr com.apple.quarantine /Applications/Edmund.app, maybe sudo). Keep those instructions accurate in every venue that mentions installing. misc/marketing/MARKETING.md gates Show HN on fixing this (notarize, or make the workaround idiot-proof).
  • GitHub issue templates exist: .github/ISSUE_TEMPLATE/bug_report.md, feature_request.md. Point users there, not at email.
  • appcast.xml is a public artifact served raw from the repo (SUFeedURL points at the raw GitHub URL). Anything committed to it is user-visible in Sparkle's update dialog. Pipeline details: edmund-release-and-operate.

5. Release-notes and public-writing style

  • Pipeline: CHANGELOG.md sections become both the GitHub release notes (awk-extracted) and Sparkle's update-dialog HTML (scripts/changelog-to-html.py → appcast <description>). A CHANGELOG entry IS public copy — write it that way. Mechanics: edmund-release-and-operate.
  • Actual house style (read CHANGELOG.md 0.1.0–0.1.3 before writing): Keep-a-Changelog headers (### Added / Changed / Fixed); one line per item, sentence case, no trailing period enforced; links to issues ([#156]) and investigation docs (([docs](docs/investigations/delete-drift-investigation.md))); user-visible phrasing ("Redo now jumps to where changed text was instead of caret") not internal jargon; occasional first-person maintainer notes with personality ("trying to have Fable 5 fix all the big bugs while I still have it with me"); 0.1.0 used bold Feature — one-line descriptions. Match this voice: plain, specific, lightly informal, zero hype.
  • Screenshots/videos: README embeds live in docs/assets/ (v0.1.0_*.png, installation.png, v0.1.0_video.mp4, AppIcon/). Raw/source marketing assets live in misc/marketing/: MARKETING.md (the plan), reddit-post.md, demo-slide-v0.1.key, demo-src-files/, demo videos (demo.mov, demo-video-v0.1-brown.mp4), _raw screenshot masters, social-preview_figma.png. New public screenshots: polished copy → docs/assets/, raw master → misc/marketing/, versioned filenames.

6. Marketing priority context (2026-07-05)

From misc/backlog.md "Now": "Priority: Marketing = Bugs >= UI/UX > Features" — marketing work is tied for top priority with bug fixes. Open marketing items: screenshots/files and a webpage (reference: kruszoneq.github.io/macUSB). Backlog embeds a star-history.com chart; misc/marketing/MARKETING.md names GitHub stars (~69 at writing) as the goal and metric, audience "developers who value craft", and holds Show HN in reserve until first-run friction and a landing page are fixed. Its "craft months" deep-dives are exactly the Section 3 candidates — which is why the proof bar there matters.

Provenance and maintenance

  • Sources verified 2026-07-05 against: README.md, docs/ROADMAP.md (last updated 2026-07-03), misc/backlog.md, docs/ARCHITECTURE.md (§2, §6, §8, §13, §14), CHANGELOG.md (0.1.0–0.1.3), LICENSE, LICENSES/lucide.txt, .github/ISSUE_TEMPLATE/, misc/marketing/, Tests/EdmundTests/PerfHarnessTests.swift, Sources/EdmundCore/Export/{ReadRenderOptions,HTMLRenderer,DocumentHTML}.swift, Sources/EdmundCore/TextView/EditorTextView.swift.
  • Volatile facts are date-stamped inline: version (0.1.3), beta status, notarization status, shipped-vs-roadmap feature split, star count, README wording. Re-verify each against the file before repeating it publicly.
  • When README differentiators or the one-liner change, update the verbatim quotes in §1 and re-run the §2 evidence check.
  • If a §3 candidate ships as a published post, move it out of "candidate" and link the post + its frozen repro.
  • Cross-references: edmund-release-and-operate (release/appcast mechanics), edmund-docs-and-writing (internal doc style), edmund-research-frontier (novelty judgment for research direction), edmund-architecture-contract (the invariants quoted here).
记录Edmund编辑器历史重大Bug调查、死胡同及被撤销的修复方案。用于在复现光标漂移、视图异常或熟悉症状前加载,避免重复尝试已知无效修复,提供症状、根因、证据及禁忌事项。
重新调查任何光标/选择区症状(如漂移、跳跃、不同步) 遇到任何视口/滚动故障(如抖动、震荡、无法向上滚动) 出现渲染阻塞问题(如裁剪换行、单行折叠、空白) 发布或更新失败(如签名错误、Sparkle签名不当) 准备提出可能已被尝试并撤销的修复方案时 Bug报告看起来似曾相识时 测试通过但应用仍表现异常时 疑惑代码中奇怪的保护措施或断言原因时
.claude/skills/edmund-failure-archaeology/SKILL.md
npx skills add I7T5/Edmund --skill edmund-failure-archaeology -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-failure-archaeology",
    "description": "The chronicle of every major bug investigation, dead end, rejected fix, and revert in the Edmund Markdown editor. Load BEFORE re-investigating any caret\/selection symptom (drift, jump, desync), any viewport\/scroll glitch (lurch, oscillation, wrong landing, can't-scroll-up), any rendering wedge (clipped wrap, one-line collapse, blank space), or any release\/update failure (signing, appcast, Sparkle \"improperly signed\"). Load before proposing a fix that might already have been tried and reverted, when a bug report \"looks familiar\", when a test passes but the live app still misbehaves, or when wondering why the code does something weird (a guard, a re-assert, a deliberately-missing icon). Every entry: symptom, root cause, evidence (commit hashes, docs), status, and what NOT to retry."
}

Edmund failure archaeology

Chronicle of settled battles. Purpose: nobody re-fights one. Each entry gives symptom → root cause → evidence → status. Hashes are on main unless noted. Dates are commit dates. Status vocabulary: settled (root-caused, fix verified), mitigated-unconfirmed (fix shipped, never seen killing a live occurrence), open (in misc/backlog.md), reverted-pending-redo.

When NOT to use this skill

  • Designing a change / asking "why is it built this way" → edmund-architecture-contract.
  • Actively debugging a NEW symptom (method, not history) → edmund-debugging-playbook.
  • Driving the live app to reproduce something → edmund-live-repro-and-diagnostics (and docs/dev-guides/live-repro-guide.md).
  • TextKit 2 / AppKit API semantics → textkit2-appkit-reference.
  • Cutting or fixing a release → edmund-release-and-operate (this file only records how 0.1.0 broke).
  • Build environment, stale-link traps in depth → edmund-build-and-env.
  • Deciding whether a change is safe to make at all → edmund-change-control.
  • Test strategy / what the suite can and cannot catch → edmund-validation-and-qa.
  • The ongoing caret-integrity program (forward-looking) → edmund-caret-integrity-campaign.
  • Debug flags (-debug.reproScript, verbose tracing toggles) → edmund-config-and-flags.

1. The delete-drift saga (rounds 1–6) — issue #156

The hardest bug in the project's history: pressing Delete moved the caret to a different line instead of deleting. Six rounds, 2026-06-25 → 2026-07-04. Full trail: docs/investigations/delete-drift-investigation.md (read it before touching +EditFlow / +SelectionTracking / the heal). One symptom, FOUR distinct root causes stacked on top of each other — each fix was real, and each round's recurrence was a different mechanism underneath.

STATUS: settled through round 6 (shipped in 0.1.3, 2026-07-04). The class — live-only caret/selection desync — remains the project's hardest problem; new rounds are possible. Backlog still lists "Delete caret drift" under On-going bugs as a class to watch, not a known unfixed defect.

Round 1 — stranded IME marked text (2026-06-26, 386604b + docs ef3d87e)

  • Symptom: once it started, EVERY delete drifted; never at launch; cleared by switching apps and back. Text stayed correct — caret-only desync.
  • Root cause: every styling path bails on hasMarkedText() (correct during live IME composition). A stranded composition (hasMarkedText() stuck true, no live composition) made didChangeText bail forever → rawSource/blocks froze while storage kept mutating → all caret math ran against a stale model. Strander: the async active-block restyle in +SelectionTracking re-checked isUpdating but not hasMarkedText(), so it could run recomposeDirty over a live composition scheduled one turn earlier.
  • Fix: (a) add the missing !hasMarkedText() guard to the async restyle; (b) becomeFirstResponder recovery hook — unmarkText() + resync when the invariant is broken on focus regain (made the user's accidental focus-switch cure deterministic).
  • Why it came back: the guard closed one strander; other marked-text sources existed (round 2), and other desync mechanisms entirely (rounds 4–6).

Round 2 — marked text without "IME" (2026-06-27, a1f3219)

  • Symptom: recurred with no CJK/accent/emoji input. Focus-switch still cured it.
  • Root cause (by elimination, documented in the doc): still stranded marked text — from automatic text completion / inline predictions, which inject provisional marked text on plain typing.
  • Fix: isAutomaticTextCompletionEnabled = false, inlinePredictionType = .no in commonInit, plus a permanent Log.info breadcrumb in the recovery hook.
  • Why it came back: the next recurrence wasn't marked text at all.

Round 3 — no fix; built diagnostics instead (2026-06-28, 5dae387, 3aaeb04, PR #139)

  • Symptom: recurred on a build with rounds 1–2. NO recovered stranded desync log line; a headless probe of the exact gesture showed the model was CORRECT. Only appeared after minutes of editing in one window.
  • Conclusion: the model/parse layer is sound; the drift is a live NSTextView / TextKit 2 / input-context phenomenon invisible headless. Chasing it blind was declared the wrong move.
  • Shipped: verbose editor tracing (Settings ▸ Advanced, Log.trace, category .edit, per-event live-state prefix) + an always-on O(1) invariant tripwire (verifyEditorInvariants, logs error on length mismatch). This instrumentation is what cracked rounds 4–6. Lesson: when a live-only bug resists reproduction, ship diagnostics, not guesses.

Round 4 — drag-move deletes with NO didChangeText (2026-07-02, 9f99795, PR #163)

  • Symptom (from the round-3 trace): drifting deletes showed shouldChangeTextnothingselectionDidChange mid-recompose with a stale caret. Origin event: a drag-select, then shouldChangeText OK repl="", then LEN-MISMATCH forever — didChangeText never fired.
  • Root cause: AppKit's drag-move gesture, when the drop lands past the end of the document (or fumbles), performs the source deletion via shouldChangeTextreplaceCharacters and never calls didChangeText. rawSource silently froze — and autosave wrote the stale bytes: this was a data-corruption bug, not just a caret bug.
  • Fix: shouldChangeText schedules a next-run-loop bypass check (RunLoop.main.perform): a pendingEdit still unconsumed one pass later == didChangeText was bypassed → run the same sync it would have, log healing storage edit that bypassed didChangeText (release too). Exempt while isUpdating/isUndoRedoing/hasMarkedText() (IME legitimately defers).
  • Why it came back: the heal restored the model but rounds 5–6 found the heal itself could move the caret.

Round 5 — the heal leaped the caret (stale selection) (2026-07-03, 422498f, docs c4a602b, merged 222dd86, PR #166)

  • Symptom: heal fired, invariant restored, bytes correct — but the caret leaped to the END of the document at the heal moment.
  • Root cause: when the bypassed deletion removes the selected text, AppKit also skips its usual selection fix, so at heal time the selection still spans deleted text (e.g. {951,37} in a 973-char doc). The heal's restyle makes AppKit re-resolve the invalid selection → clamps to document end.
  • Fix: before syncing, the heal collapses an out-of-bounds selection to the edit point. (Headless NSTextView clamps this itself — the test documents intent; only live layout reproduces the leap.)
  • Why it came back: this out-of-bounds clamp was a special case of the real mechanism, found in round 6.

Round 6 — TextKit 2's queued selection fixup: the drift mechanism itself (2026-07-04, 1b1420a, branch fix/wrapped-paragraph-caret-drift, merged 218d922, PR #169)

  • Symptom: typing mid wrapped paragraph, one backspace leaped the caret +43 ("two viewport-lines down"); drift no longer continuous — one delete drifts, the next ones don't. Model fine; a heal had fired 80 seconds EARLIER.
  • Root cause (named via a traceSelectionOrigin stack capture): a normal edit runs TextKit 2's _fixSelectionAfterChangeInCharacterRange synchronously inside its own transaction. A didChangeText-bypassing mutation skips that too, so the fixup stays queued and fires at the NEXT endEditing — the heal's attribute-only restyle — where it maps the stale selection against post-edit coordinates and drops the caret blocks away. Fires exactly once (state is then synchronized), explaining "drifts once, then fine". Round 5's clamp was the sub-case where the stale selection ran past the shrunk document end.
  • Fix: the heal derives the correct caret from the pendingEdit hull and sets it both before AND after syncRawSourceFromDisplay(). The before-only version still leaped — the queued fixer moves even a freshly set, fully valid caret during the sync's endEditing. The post-sync re-assert is the load-bearing half; the pre-set keeps cursorRaw/active-block styling correct.
  • The breakthrough repro (first deterministic one in six rounds): ReproScript.swift (DEBUG-only, -debug.reproScript <path>) replays keystrokes in-process through window.sendEvent(_:) — no TCC, works on an invisible Space. bypassdelete <needle> simulates the drag-move deletion exactly; one bypass beforehand → the next delete always drifts. Typing alone never drifts. See edmund-live-repro-and-diagnostics.

Do not retry (proven dead across the saga)

  • Headless/unit tests for this class. The test harness runs TextKit 2's selection fixups synchronously, so the deferred-fixup state never forms. The round-6 unit test passes with and without the fix — it is a contract spec, not a regression guard. Only the ReproScript live repro discriminates. Do not "add a test to catch it" and call the class covered.
  • CGEvent injection as the default live driver. Round 6's session dropped the events (per-session TCC), and the app's windows launch on an inactive Space. Use ReproScript first.
  • DEBUG assertion in didChangeText's marked-text guard — rejected in round 1: the invariant is legitimately broken during composition; it false-fires on every IME keystroke.
  • "No explicit selection repair needed" (round 4's claim) — wrong twice. Any new heal-like path must handle selection explicitly, before and after.
  • swift build trusted after "Build complete!" — round 6 hit a stale-link relink failure TWICE; two "failed" fix iterations were phantoms running old code. Verify with strings on a LONG literal (≤15-byte literals inline on arm64 and never show), cure with swift package clean, never hand-delete edmd.build/. Details: edmund-build-and-env.

2. Undo/redo viewport drift — the costliest failure

STATUS: settled (2026-07-02, 5bb2b40, part of PR #164) — with one caveat: misc/backlog.md "Lurking (Unreproduceable)" carries a later note "Undo/Redo and Copy/Paste scrolling is failing again". No repro exists. Treat the mechanism below as settled and any new report as a NEW investigation that starts from this entry.

  • Symptom: undo scrolled too far down; redo centered on wherever the caret sat before the undo; changed text never selected.
  • Root cause (two defects, found by code read before any experiment):
    1. restoreSnapshot ran a full recompose — replacing the entire storage discards every TextKit 2 layout fragment, resetting ALL geometry to height estimates; the subsequent centering math measured estimates.
    2. performUndo recorded the redo snapshot with the caret at undo invocation time (stale), and redo centered on it.
  • Fix: textDiff(old:new:) single contiguous changed span → range-bounded recomposeReplacing (layout outside the span stays real); the changed range — never a stored caret — is selected and drives the viewport (hold if visible, else center).
  • Load-bearing contract: never full-recompose on undo/redo. Anyone "simplifying" restoreSnapshot back to recompose reintroduces the bug. Guarded by TextDiffTests, UndoRedoSelectionTests, UndoRedoViewportTests.
  • Prior art that treated symptoms without naming the estimate problem: 9aaa11b (undo hold-or-center), 2778d6e/21cc284 (cursor-move lurch), 84123e4 (pin scroll above viewport), c49cd5c (lazy viewport-first styling). All sound, all workarounds; 5bb2b40 removed the manufactured estimates at the source.

3. Viewport glitches — TextKit 2 height estimates (PR #164, 2026-07-02)

Full trail: docs/investigations/viewport-glitch-investigation.md. Three reported symptoms, one root cause: every off-screen TextKit 2 frame is an estimate; code that discards layout, trusts off-screen y, or runs two scroll policies at once turns estimate churn into visible jumps. Community-documented (Krzyżanowski / STTextView, Apple forums) — even TextEdit exhibits it.

  • Bug 1 (undo/redo): entry 2 above. STATUS: settled (same caveat).
  • Bug 2 (editing at top pushes line 1 above the viewport, can't scroll up). Theory: TK2 assigns fragments negative y when estimates above the viewport correct downward; scroller clamps at 0. Mitigations 217da5f (documents ≤100k UTF-16 kept fully laid out via a deferred scheduleFullLayoutSettle — estimates never exist) and 8b4ecfe (repairContentAboveOrigin: first fragment minY < -0.5 → re-lay start→viewport-end inside preservingViewportAnchor; breadcrumb repairing content above origin). STATUS: mitigated-unconfirmed. The doc is explicit: Bug 2 was never reproduced live; the repair had not been confirmed against a live occurrence as of this writing (2026-07-05). If it recurs: grep ~/.edmund/logs for the breadcrumb — present means diagnosis confirmed but repair raced/undersized; absent means different cause (scroller-only estimate jumps, or textContainerOrigin).
  • Bug 3 (viewport oscillates during a steady drag-select). Two scrollers fighting: drag autoscroll pulling down vs the scrollRangeToVisible override always revealing the selection top once the selection outgrew the viewport. Fix 340fcbc: reveal the nearest end. STATUS: settled by geometry/reasoning — a live drag was never synthesized (that session couldn't arm AppKit selection). Phase 1 of the same report ("can't select") was NOT a bug: a whole-doc selection was active, so the drag was AppKit's drag-move gesture — same family as delete-drift round 4.
  • Do not retry: raising fullLayoutMaxLength without measuring ensureLayout cost (full layout on large docs is the process-killing path that motivated the lazy pipeline); running a full layout inside a caller's preservingViewportAnchor (poisons its before/after measurement — the tab-indent stability test caught a 366pt compensation; that's why the settle is deferred).
  • Backlog keeps "Inaccurate viewport estimates and things related" under On-going bugs, plus a lurking "glitch when scrolling" — the estimate class is managed, not extinct. Roadmap v1.0.0 carries "TextKit 2 viewport stabilization".

4. Callout-title wrap / the image wedge (settled 2026-07-03, aa45563 + ae61644, PR #165)

Full trail: docs/investigations/archives/callout-title-wrap-investigation.md — including a 10-row matrix of dead ends.

  • Symptom/goal: custom callout titles (> [!type] Title) should render as real wrapping text with the type icon; any attempt to draw the icon clipped the wrapped title to one line.
  • Root cause: drawing any IMAGE on a multi-line layout fragment wedges that fragment to a single line — an unexplained TextKit 2 reentrancy quirk. Isolated exhaustively: fragment overlay, frame-relative draw, before/after super.draw, raw CGContext.draw, editor-level draw(_:), pre-rasterized bitmap, transparent subview, layer-backed subview, CALayer contents — ALL clip. Controls: no icon → wraps; positions computed but plain rect filled instead of the image → wraps. Reading layout is fine; drawing a shape is fine; drawing an image is not.
  • Fix: the icon is a stroked CGPathSVGPath parses the vendored Lucide geometry, FragmentOverlay gained a path form, DecoratedTextLayoutFragment strokes it in CG. Verified live: icon renders, long titles wrap and re-wrap on resize.
  • Standing constraint: any future overlay that can share a line with wrapping text MUST use the path form, never the image form. Existing image overlays (math, bullets, default callout header) survive only because they sit on single-line fragments.
  • Do not retry: any image-drawing mechanism from the matrix; bumping the deployment target to macOS 15 on the hope newer TextKit 2 fixed it (no evidence, drops Sonoma incl. the dev machine). The whole-header-as-image alternative is preserved on branch fix/callout-title-image (tip c7f5170, unmerged): it sidesteps the wedge but has two unsolved problems (~2× line height band above the title; a width-timing race).
  • Still open nearby (backlog): callout icon baseline / crispness; the callout-at-end-of-file extra colored line (live-path rendering bug).

5. The 0.1.0 release failures (2026-06-26 → 2026-07-02)

Reference: ARCHITECTURE §13. Five separate failures shipping and updating the first releases. STATUS: all settled, with one time bomb (PAT expiry).

  1. sign_update -s exits 1 for new keys (59565f5, 2026-06-27, PR #135). Sparkle deprecated -s <key>; for keys generated after that change it prints a warning and exits 1. Killed the first 0.1.0 release. Fix: key on stdinecho "$SPARKLE_ED_PRIVATE_KEY" | sign_update --ed-key-file - <dmg>. Do not retry -s.
  2. Bundle not sealed → EVERY update failed "improperly signed" (5e54b40, 2026-06-29, issue #158). Sparkle re-validates the Apple code signature at install (SUUpdateValidatorSecStaticCodeCheckValidity); the build signed only the main binary, never sealed the bundle, so a valid EdDSA signature didn't save it. Fix: codesign --deep the whole .app — and because SwiftMath's resource bundle must sit at the .app root (Bundle.module hardcodes Bundle.main.bundleURL) and codesign won't seal a bundle with root items, seal first, copy the SwiftMath bundle in AFTER sealing. The lone unsealed root item trips strict codesign --verify but not Sparkle's non-strict check (verified against that exact API). Do not "fix" the ordering or the failing strict verify.
  3. Appcast push to protected main rejected, GH006 (e56a4dd, 2026-06-28, PR #140). GITHUB_TOKEN isn't admin; enforce_admins: false means an admin PAT bypasses the required check. Fix: fine-grained admin PAT in secret RELEASE_TOKEN, set on the checkout step (not the push URL — actions/checkout persists an extraheader credential that overrides inline-URL creds). RELEASE_TOKEN expires 2027-06-27; rotate before then or releases fail at the appcast push.
  4. create-dmg quirks (098d8c0, 2026-06-26, documented in §8): it's the npm create-dmg (sindresorhus), not the Homebrew tool; Node ≥20; exit code 2 for unsigned images is success; space-in-filename normalization.
  5. Release workflow YAML invalid (854f85d, 2026-07-02, merged 232e6c8, PR #162). Literal multi-line bash strings inside a run: | block had unindented lines — invalid in a YAML block scalar; the whole workflow file failed to parse. Fix: build the strings with printf \n escapes; also $(...) strips trailing newlines, so the separator newline lives in NEW_ITEM's format string, not DESC_BLOCK.

6. Reverts and abandoned directions

  • Selection tintee173f7 (2026-06-26): reverted an experimental selection color back to accent-derived (accent @ 30% alpha). STATUS: settled. Don't re-hardcode a bespoke selection color.
  • img.md-image { display:block } in the export/Read HTML theme75d2824 (2026-06-25): reverted; it did NOT fix the image blank-space and broke layout. The commit title itself records the verdict: image blank-space is a separate, STILL-OPEN bug (backlog: "Attached image padding… creates a large empty space below", misc/bug-repros/image-blank-after.mov). Do not retry display:block for it.
  • Checkbox click-to-toggle + table borders9991413 (2026-06-03): pulled from a branch for separate passes. STATUS: partially redone. Table work landed later (edit-mode table alignment shipped, per backlog Done); Read-mode click-to-toggle checkbox is still in the v1.x backlog — reverted-pending-redo. The original attempt lives on stale branch feature/checkbox-toggle (merged history, d6227e3).
  • TextKit 2 migration regressionsb3a4b29 (2026-06-12): the TK1→TK2 migration silently broke inline-math height, HR spacing, and scroll; fixed with RenderingRegressionTests as the guard. Lesson: TK2 migrations regress silently in geometry — extend that suite when touching layout.
  • Stale branches that look abandoned but are MERGED (don't "rescue" them): feature/incremental-recompose (tip 1222f79, in main) and refactor/word-level-rendering (merged via PR #8, 7eb6a21 — word-level delimiter hiding became the shipped approach). The genuinely unmerged WIP branches are fix/callout-title-image (entry 4) and dozens of old merged topic branches never deleted.

7. Wrapped-paragraph caret drift (PR #169) — same battle as round 6

fix/wrapped-paragraph-caret-drift / merge 218d922 IS delete-drift round 6 (entry 1): the branch name comes from the reporting symptom (backspace mid wrapped paragraph), but the root cause was the queued TextKit 2 selection fixup armed by an earlier bypassed drag-move edit — the wrapped paragraph was incidental. STATUS: settled with 1b1420a. If a caret drift is reported "in a wrapped paragraph", do not assume wrapping is the mechanism; check for a preceding heal breadcrumb in ~/.edmund/logs first.


8. Smaller settled battles (one paragraph each; verified in git)

  • Flaky math fit-width test — took TWO rounds (5421297 2026-06-06 branch fix/flaky-math-test, then 352bdc9 PR #65). First fix pinned the text container width (tracking off) — the flake persisted ~1 in 3 runs. Real cause: shared theme-defaults state pollution between tests; fixed with isolated defaults. Lesson: a flake "fix" that doesn't name the shared state isn't done.
  • Emoji rendered as missing-glyph boxes (0f5ffba, 2026-06-06). EditorTextStorage.fixAttributes is a deliberate no-op (so .attachment on real characters survives) — which also disabled font substitution. Fix: perform substitution manually (Apple Color Emoji per composed-character sequence, ZWJ/skin-tone graphemes whole). Don't re-enable the framework fixAttributes; it strips the marker attachments.
  • Nested list hanging indent (8d2088f, 2026-06-06, PR #64). swift-markdown's list-item delimiter excludes leading indentation; the visible spaces broke the hang. Fix: hide the leading whitespace in the inactive branch; indentation comes entirely from the paragraph style.
  • Ordered-list deep nesting lost styling (b255903, 2026-06-06). swift-markdown parses ≥4-space indent as indented code; the rescue regex only matched [-*+]. Extended to \d{1,9}[.)]. Any new list-ish syntax must be added to the rescue parser too.
  • Window size persistence — three commits to get right (538ff6ed967bcf678c5d6, 2026-06-28, PR #144). Saving content-view size made windows grow taller on reopen (titlebar double-counted); final form stores the full window frame and restores via setFrame.
  • Toolbar right-click interception — three failed view-level attempts (4eb604a, 6723280, then f8472ca, 2026-06-26). View .menu, rightMouseDown, and a gesture recognizer all lost to the toolbar's "Customize Toolbar…" menu. Working fix: DocumentWindow overrides NSWindow.sendEvent — the documented funnel ahead of the toolbar — and swallows secondary clicks on the view-mode button. Do not retry view-level interception for anything the titlebar/toolbar claims.
  • Invisible CJK input (a3df387): IME-composed text was invisible until committed; fixed by keeping marked text visible. Related to (and predating) the round-1 marked-text rules.

9. Still OPEN — do not let this chronicle imply otherwise

Per misc/backlog.md (cross-checked 2026-07-05): footnotes don't render (edit or Read); math doesn't render in Read mode; math padding in edit mode; image blank-space below attached images (see the 75d2824 revert); callout-at-end-of-file extra colored line; max content width not applied to Read mode; tables don't wrap/shrink at small content sizes; table-cell content wraps out of the cell; "sometimes click to select / select+delete doesn't work" (unreproduced); lurking scroll glitch from off-viewport height changes; lurking indented-cursor-stuck report; lurking "undo/redo and copy/paste scrolling failing again" (see entry 2 caveat). Delete-caret-drift and viewport-estimate classes stay on the watch list even though every known mechanism is fixed.


Provenance and maintenance

  • Written 2026-07-05 by mining: docs/investigations/delete-drift-investigation.md, docs/investigations/viewport-glitch-investigation.md, docs/investigations/archives/callout-title-wrap-investigation.md, docs/ARCHITECTURE.md §13, CHANGELOG.md, misc/backlog.md, docs/ROADMAP.md, and git log --all (every hash above verified with git show on that date).
  • Code and git win over prose. If this file disagrees with a commit or the current source, trust the commit, then fix this file.
  • Update triggers: a new delete-drift round (append to entry 1 — never a new doc); any live confirmation or refutation of repairContentAboveOrigin (flip entry 3 Bug 2 off mitigated-unconfirmed); RELEASE_TOKEN rotation (entry 5); any revert (entry 6); closing a backlog bug named in entry 9.
  • Keep the status vocabulary exact; "mitigated-unconfirmed" is not "fixed". No oversell — this file's value is that its claims can be trusted blind.
  • Sibling map lives in "When NOT to use" above; keep it in sync as the skill library grows.
用于测量和诊断 Edmund 应用中的实时行为问题(如光标、IME、拖拽)。提供从单位测试到脚本驱动的重现阶梯,包含 ReproScript 驱动器和屏幕截图测量工具,解决无头测试无法复现的缺陷。
涉及实时行为(光标、IME、拖拽、视口时序)的 Bug 单元测试无法复现报告的问题 需要读取诊断追踪或测量截图像素
.claude/skills/edmund-live-repro-and-diagnostics/SKILL.md
npx skills add I7T5/Edmund --skill edmund-live-repro-and-diagnostics -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-live-repro-and-diagnostics",
    "description": "How to MEASURE Edmund instead of eyeballing it — the diagnostic tools, interpretation guides, and working scripts. Load when a bug involves LIVE behavior (caret, IME, drag, viewport timing), when a unit test cannot reproduce a report, when you need to read diagnostic traces, drive the running app with scripted keystrokes, or measure pixels from a screenshot. Contains the repro escalation ladder, the ReproScript driver, the CGEvent fallback, and screencapture measurement, plus scripts\/ helpers. Not the symptom→mechanism table (edmund-debugging-playbook), not the campaign (edmund-caret-integrity-campaign), not build hygiene (edmund-build-and-env)."
}

Edmund live repro & diagnostics

A class of Edmund bugs lives in the live NSTextView / TextKit 2 / input-context layer (deferred selection fixups, IME composition, drag sessions, event-loop timing). Headless tests cannot form the broken state — the test harness runs AppKit's deferred machinery synchronously, so a green unit test proves nothing about this class. This skill is how you make such a bug cheap to observe, then deterministic. Primary source doc: docs/dev-guides/live-repro-guide.md.

Verified 2026-07-05.


0. Safety preamble (do this every time)

  • Check for the user's live instance first. The user's daily-driver app has the same binary name (edmd). Run scripts/check-live-instance.sh. Never blanket pkill -x edmd — kill only your own PID, or use pkill -f EdmundDbg (only your debug bundle matches).
  • Recreate the test document fresh before every run — autosave mutates it; run 2 against run 1's leftovers produces garbage offsets.
  • Verify the binary is fresh before trusting a run (SwiftPM sometimes prints Build complete! without relinking edmd) — strings/shasum method in edmund-build-and-env.
  • Do not request macOS Computer Access. Screen Recording + Accessibility are already granted for this project; ReproScript (§3) needs neither.

1. The escalation ladder

Work down; stop at the first level that reproduces. Each is more faithful and more expensive than the one above.

# Technique Faithful to Cost Use when
1 Plain unit test (makeEditor()) model/parse/style logic seconds anything not event-timing-dependent
2 Windowed unit test (NSWindow + NSScrollView, real deleteBackward(nil)) + layout, viewport, first responder seconds viewport/lazy-styling bugs (LazyRenderingTests setup)
3 In-process ReproScript (§3) + real key path, run-loop pacing, real process ~1 min/run anything keyboard/edit-pipeline shaped — the default for live bugs
4 CGEvent driver (§4) + real HID events, real mouse (drags!) TCC-dependent mouse-only paths: drag-select, drag-move, autoscroll
5 Instrumented field occurrence everything days can't trigger it — instrument first (§2), decide on the next hit

Two levels deserve emphasis:

  • Level 2 failing to repro is evidence, not defeat — it tells you the bug needs deferred/queued AppKit state, pointing you at level 3–4.
  • Level 3 exists because level 4 is unreliable — background/agent sessions often have no TCC grant and synthetic keyboard events get dropped silently. In-process injection needs no permission.

2. Step 0 — make the trace tell you the trigger

Never script blind. The recipe is usually already in ~/.edmund/logs, if verbose diagnostics were on. Launch flags (file arg must be argv[1]):

<app>/Contents/MacOS/edmd FILE.md \
  -settings.general.diagnosticLogging YES \
  -settings.advanced.verboseEditorDiagnostics YES

Trace-field decoder (each verbose line carries these; from EditorTextView+Diagnostics.swift):

Field Meaning
sel current selection {location,length}
active active (caret) block index
marked is there marked/IME text
up isUpdatingup=Y = event arrived mid-recompose (suspicious)
undo undo-stack depth
blocks block count
storLen / rawLen storage length vs rawSource length
  • Healthy edit ordering: shouldChangeTextselectionDidChange (up=N) → synced. A transient ⚠︎LEN-MISMATCH between those lines is normal (storage moves before rawSource syncs).
  • Suspect: a selectionDidChange with up=Y at a surprising position; a persisting LEN-MISMATCH; a shouldChangeText with no synced/SKIPPED/DEFERRED after it (a bypassed didChangeText); the healing storage edit that bypassed didChangeText breadcrumb.
  • traceSelectionOrigin logs a call stack for any selection change that lands mid-recompose — this is what named _fixSelectionAfterChange in round 6.
  • Walk BACKWARDS from the first bad line, not forwards from the symptom. The round-6 drift was armed ~80 seconds and dozens of healthy edits before the visible failure. The user-visible symptom is often the second half.

If the log didn't capture the deciding fact, add the log line first (keep good ones behind Log.shouldTrace and ship them) and reproduce again. Also reconstruct the document — wrapped-paragraph geometry, block boundaries, and block kinds all matter; repro against a lookalike, never "hello world".

scripts/grep-trace.sh [YYYY-MM-DD] surfaces the suspect patterns in one shot.


3. The in-process ReproScript driver (default for live bugs)

Sources/edmd/App/ReproScript.swift, DEBUG builds only. Replays a keystroke script against the front document by synthesizing NSEvents and pushing them through window.sendEvent(_:) — the full authentic key route (keyDown → interpretKeyEventsinsertText: / deleteBackward:). No Accessibility, no visible window required (works on an inactive Space), real run-loop pacing.

Launch: scripts/launch-debug.sh FILE.md SCRIPT.repro (assembles EdmundDbg.app, guards the user's instance, direct-execs with all flags). Or by hand:

build/EdmundDbg.app/Contents/MacOS/edmd "$DOC.md" \
  -settings.general.diagnosticLogging YES \
  -settings.advanced.verboseEditorDiagnostics YES \
  -debug.reproScript "$SCRIPT.repro" \
  -ApplePersistenceIgnoreState YES &

Command surface (one per line, # comments allowed):

Command Effect
sleep <ms> wait before the next command
caret <needle> place caret before the first occurrence of <needle>
type <text> one real key event per char, ~80 ms apart
backspace <n> n real delete keystrokes, ~300 ms apart
bypassdelete <needle> simulate the drag-move source deletion: select range, shouldChangeText + storage mutation, no didChangeText
assertcaret <needle> log repro assertcaret PASS/FAIL sel=… want=… iff caret sits exactly before <needle>
logsel log selection, rawSource length, doc count

Round-6 minimal repro (the worked example): the deciding output was logsel 321 (broken) → 290 (fixed), every run, window not even visible.

sleep 2000
bypassdelete Sizemore,
sleep 800
logsel            # broken: {321,…}; fixed: {290,…}
backspace 2
logsel

Design rules — keep them when extending:

  • Address text by needle, never offset — offsets go stale the moment a script edits; needles survive (this is what makes soak scripts possible).
  • Real events over direct method callsinsertText("") shortcuts skip deleteBackward's selection machinery, the exact place round 6 lived.
  • Simulate AppKit-internal paths by exact call sequencebypassdelete replicates shouldChangeTextreplaceCharacters, no didChangeText verbatim, not an approximation. Pin any new internal path's real sequence from a traceSelectionOrigin stack first, then replay it.
  • Asserts inside the app, results in the log — the harness (you, or a shell loop) only greps PASS/FAIL; the app is the oracle.
  • New commands are ~10 lines each — extend ReproScript.swift, don't work around it.

Soak scripts (§6): chain several trigger cycles at different positions with ordinary editing between them, assertcaret after each predictable step, and compare final rawLen across runs (byte-identical = deterministic). A soak green across 4–5 cycles is far stronger than one clean repro — it catches bugs needing armed state (round 6's queued fixup).

bypassdelete Sizemore,
assertcaret Strang
backspace 2
type xy
bypassdelete widely
assertcaret used in various
logsel

4. CGEvent driver (mouse-only paths, TCC willing)

For paths that must originate as HID events — real drag-select, drag-move, autoscroll — keyboard replay can't cover them. A ~70-line ui.swift (compile with swiftc) posting CGEvents does: bounds <substr> (window lookup via CGWindowListCopyWindowInfo), click x y, dragselect, dragmove (mousedown + ~400 ms hold before moving, or AppKit never arms the text drag), key, type.

Caveats (all hit in practice):

  • TCC decides per session. Background/agent sessions often can't post keyboard events (dropped silently) or use System Events. Test with **one click
    • log check**; if input doesn't land, fall back to §3 immediately — don't iterate on driver variations.
  • App windows are on an inactive Space until activated (kCGWindowIsOnscreen == false); osascript -e 'tell application "<path>.app" to activate' (Apple Events, a separate TCC bucket) may work where System Events is denied.
  • Re-activate before every interaction batch; focus is lost between shell calls.

5. Screencapture measurement

Visual judgments are measured, not eyeballed — when the task says "balance padding" or "align the icon", capture the window and measure pixels.

scripts/capture-window.sh <window-title-substring> out.png finds the window id (JXA → CGWindowListCopyWindowInfo) and runs screencapture -x -o -l<id>. Notes:

  • Capture by window id, reliable even when not frontmost.
  • Crop by the detected window bounds — the desktop wallpaper defeats screencapture's brightness-based auto-crop.
  • Measure padding/alignment from the PNG (e.g. a short Python/PIL pixel scan for the first/last colored row of a callout box). Report the pixel numbers, not an impression.
  • Window-server state can glitch (tiny windows, restoration) after many rapid launch/kill cycles: rm -rf ~/Library/"Saved Application State"/com.i7t5.edmund.savedState and relaunch.

6. The loop, end to end

  1. Verbose trace from the occurrence → find the first bad line, walk backwards, form a trigger hypothesis (§2).
  2. Reconstruct the document; script the hypothesized trigger (§3).
  3. No repro? Hypothesis wrong or fidelity too low — move down the ladder (§1), or instrument and wait for the next hit.
  4. Repro in hand? Freeze it (exact script + document), then let it falsify fix candidates — round 6's first "fix by reasoning" failed in the repro within a minute.
  5. Fix verified → soak (§3) → full swift test → keep the script + new diagnostics → update the relevant docs/*-investigation.md.

Meta-lesson from six rounds: time spent making the failure cheap to observe beats time spent reasoning about the fix. Every round that shipped on reasoning alone came back; the round that shipped on a deterministic repro named the actual mechanism.


scripts/ (in this skill dir)

Script Purpose Status
check-live-instance.sh Report running edmd, exit 2 if any; never kills logic verified; safe by construction
grep-trace.sh [date] Surface suspect patterns in today's log logic verified
capture-window.sh <needle> <out.png> Screenshot a window by id + report bounds verify on first use (JXA CGWindowList lookup not executed this session)
launch-debug.sh <file.md> [script.repro] Build + assemble EdmundDbg.app + direct-exec with flags verify on first use (assumes arm64 debug triple; guards user instance)

All four pass bash -n. The two "verify on first use" scripts depend on live system state (window server, build layout) that couldn't be exercised while authoring; read the header comment before first run.


When NOT to use this skill

  • Deciding which mechanism a symptom implies → edmund-debugging-playbook.
  • Running the full caret-integrity investigation → edmund-caret-integrity-campaign.
  • Stale-binary detection / bundle internals → edmund-build-and-env.
  • What counts as sufficient evidence to ship → edmund-validation-and-qa.
  • The AppKit theory behind the fixup/marked-text mechanisms → textkit2-appkit-reference.

Provenance and maintenance

Verified 2026-07-05 against docs/dev-guides/live-repro-guide.md, Sources/edmd/App/ReproScript.swift, and Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift.

grep -oiE '"(sleep|caret|type|backspace|bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u
grep -n 'debug.reproScript' Sources/edmd/App/ReproScript.swift
grep -rn 'traceSelectionOrigin\|LEN-MISMATCH\|shouldTrace' Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift Sources/EdmundCore/Diagnostics/Log.swift
grep -n 'healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift

Re-verify the scripts against docs/dev-guides/live-repro-guide.md §4 if the debug-bundle assembly recipe changes.

用于 Edmund 应用发布与运维的指南。涵盖版本变更、打标签、CI 构建、签名及 DMG 制作流程,并处理 Sparkle 更新失败、Gatekeeper 拦截、崩溃日志分析及应用启动等运维问题。
执行版本号和构建号变更 创建或推送 vX.Y.Z 标签 维护 CHANGELOG.md 发布章节 Sparkle 更新失败或签名错误 DMG 制作或 Gatekeeper 报错 读取崩溃报告或应用日志
.claude/skills/edmund-release-and-operate/SKILL.md
npx skills add I7T5/Edmund --skill edmund-release-and-operate -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-release-and-operate",
    "description": "Load when cutting or debugging an Edmund release, or operating the shipped app. Triggers: version bump (Info.plist CFBundleShortVersionString \/ CFBundleVersion), tagging vX.Y.Z, CHANGELOG.md release sections, release.yml \/ release.sh \/ build-app.sh, appcast.xml or Sparkle update failures (\"improperly signed\", update never offered), sign_update \/ EdDSA keys \/ SPARKLE_ED_PRIVATE_KEY \/ RELEASE_TOKEN, create-dmg or DMG naming problems, Gatekeeper \"damaged\" reports, launching the built app, reading ~\/.edmund\/logs, crash reports (edmd-*.ips), or roadmap\/priority questions."
}

Edmund — release & operate

Date-stamped 2026-07-05. Verified against .github/workflows/release.yml, scripts/release.sh, scripts/build-app.sh, scripts/changelog-to-html.py, appcast.xml, Info.plist, CHANGELOG.md, docs/ARCHITECTURE.md §8/§13, and the Settings/CrashReporter sources. Where a doc and a script disagree, the script is the truth; disagreements are flagged inline.

House rule: releases happen only when the maintainer explicitly asks. Never tag, push, create a release, or merge on your own initiative — see edmund-change-control. Everything in §1–§4 below is a runbook for when the maintainer says "cut a release", not a standing instruction.

When NOT to use this skill

You actually need Go to
Build/test commands, stale-build cures, launch mechanics in depth edmund-build-and-env
Editing invariants, render pipeline, TextKit 2 rules edmund-architecture-contract, textkit2-appkit-reference
Debugging a bug in the app itself edmund-debugging-playbook, edmund-live-repro-and-diagnostics
Past incidents and why the sharp edges below exist edmund-failure-archaeology
Debug flags / launch arguments edmund-config-and-flags
Branch/commit/PR etiquette, what needs maintainer sign-off edmund-change-control
Pre-merge QA method edmund-validation-and-qa
README/website/positioning copy edmund-docs-and-writing, edmund-external-positioning

1. Release flow — CI path (the normal one)

Ship via a tag; CI does the rest. In order:

  1. Bump versions in Info.plist — both keys:
    • CFBundleShortVersionString — marketing version, e.g. 0.1.3
    • CFBundleVersion — build number, monotonic integer (0.1.3 = 4)
  2. Add a ## [x.y.z] section to CHANGELOG.md — format is load-bearing, see §2. The version MUST match Info.plist exactly.
  3. Merge to main and push (via the normal PR flow).
  4. Tag and push the tag:
    git tag vX.Y.Z && git push origin vX.Y.Z
    
  5. CI (.github/workflows/release.yml, trigger push: tags: 'v*', runner macos-14, job release / "Build & publish") runs the steps below.
  6. Afterwards, verify per §4 post-flight.

release.yml step anatomy (actual step names)

Step What it does Sharp edge
actions/checkout@v5 fetch-depth: 0, token: ${{ secrets.RELEASE_TOKEN }} The PAT must be on this step — see §3.4
maxim-lobanov/setup-xcode@v1 latest-stable Xcode
Cache .build SPM cache keyed on Package.resolved
Build app bundle ./scripts/build-app.sh — release build, bundle assembly, Sparkle embed, bundle sealing §3.2
actions/setup-node@v4 pins Node 20 create-dmg 8.x needs Node ≥ 20
Install create-dmg npm install --global create-dmg (sindresorhus/create-dmg, not Homebrew's) §3.5
Create DMG reads VERSION from Info.plist, create-dmg build/Edmund.app build/ || true, renames "Edmund <v>.dmg"Edmund-<v>.dmg, fails loudly if no dmg §3.5
Sign archive (EdDSA) finds sign_update in .build, key on stdin via --ed-key-file -, exports ED_SIG and LENGTH §3.1
Create GitHub Release awk-extracts the CHANGELOG section → gh release create "v${VERSION}" build/Edmund-${VERSION}.dmg --title "Edmund ${VERSION}" --notes-file … --latest §2
Update appcast.xml builds the new <item> (HTML <description> via scripts/changelog-to-html.py), inserts it before </channel>, commits as github-actions[bot], git push origin HEAD:main §3.4

Local path (scripts/release.sh)

Mirrors CI: build-app.sh → create-dmg (+ rename) → EdDSA sign → update appcast.xml locallygh release create. Two differences:

  • Signing: with SPARKLE_ED_PRIVATE_KEY in the env it uses the stdin path (CI-style); otherwise sign_update pulls the key from the login keychain (put there by Sparkle's generate_keys) with no flag at all.
  • The appcast commit/push is left to you. The script ends with the exact commands: git add appcast.xml && git commit -m 'Release <v>' && git push.

Prereqs for the local path: gh auth status authenticated, npm create-dmg installed, swift build has run at least once (so sign_update exists under .build).

Stale doc: misc/how-to-release.md still says the artifact is a zip ("signs the zip", "Zip it to build/Edmund-1.0.zip"). That predates the DMG switch. The truth is DMG throughout — per release.yml, release.sh, and ARCHITECTURE §13. Trust the scripts, and fix that doc when touching it.


2. CHANGELOG format contract (release notes are machine-extracted)

Both release.yml and release.sh extract the GitHub Release body with:

awk "BEGIN{p=0} /^## \[${VERSION}\]/{p=1;next} p && /^## \[/{exit} p{print}" CHANGELOG.md

So the section header must start at column 0 as ## [x.y.z] — literally ## [0.1.3] — 2026-07-04 in house style (em dash + ISO date after the bracket is fine; the match only requires the ^## \[x.y.z\] prefix). Extraction runs until the next ^## [ line. If nothing matches, the release body falls back to "See CHANGELOG for details." — a silent-ish failure, so get the header right. (Version dots are unescaped in the regex; harmless in practice, don't rely on it.)

The Sparkle update-dialog notes come from the same section via scripts/changelog-to-html.py <version>, a deliberately tiny converter that only understands Keep-a-Changelog shapes:

  • ### Added / ### Changed / ### Fixed<h3>use ###, not ##. The 0.1.2 appcast item literally shows <p>## Changed</p> because the section used ## subheads at release time; the converter passed them through as paragraphs.
  • - / * bullets → <ul><li>; indented continuation lines fold into the previous bullet.
  • `code` and **bold** are converted. Markdown links are NOT[docs](docs/foo.md) appears literally in the update dialog (see the 0.1.2 item). Keep appcast-facing notes link-free or accept the raw brackets.
  • Blank lines and --- are skipped; anything else becomes a <p>.
  • Missing section → empty output → the <description> is simply omitted.

House format (verified from CHANGELOG.md): Keep a Changelog 1.1.0 + SemVer, newest first, sections separated by ---.


3. The sharp edges (each one killed or nearly killed a real release)

3.1 sign_update -s is FATAL — key goes on stdin

Sparkle deprecated -s <key>; for newly generated keys it prints a deprecation warning and exits 1 ("no longer supported"). This killed the first 0.1.0 release. The only correct invocation with a key-in-hand:

echo "$SPARKLE_ED_PRIVATE_KEY" | sign_update --ed-key-file - <dmg>

Both release.yml and release.sh do exactly this. Never "simplify" it back to -s. Output format: sparkle:edSignature="<sig>" length="<n>" — the scripts grep those two attributes out for the appcast item.

3.2 Bundle sealing (the "improperly signed" update failure)

At install time Sparkle re-validates the update's Apple code signature (SUUpdateValidator), independent of the EdDSA signature. A bundle that is code-signed but not sealed (no _CodeSignature/CodeResources) fails that check and every update dies with "The update is improperly signed and could not be validated" — which is exactly what broke the v0.1.0 → 0.1.1 update when the old script signed only the bare binary.

build-app.sh therefore signs inside-out and in a very deliberate order:

  1. codesign --force --deep --sign - Sparkle.framework (nested XPC helpers must be signed before macOS will launch them),
  2. codesign --force --deep --sign - --identifier "com.i7t5.edmd" on the whole .app while its root holds only Contents/ — codesign refuses to seal a bundle with extra items at the root,
  3. copy the SwiftMath resource bundle to the .app root after sealing (its generated Bundle.module looks at Bundle.main.bundleURL; without it the app crashes on the first LaTeX render).

Consequence: codesign --verify (CLI) and --strict will complain about that one unsealed root item. That is expected and fine — Sparkle's actual check is non-strict (SecStaticCodeCheckValidityWithErrors with kSecCSCheckAllArchitectures) and tolerates it; verified end-to-end against that API. Do not "fix" the verify warning by moving the SwiftMath bundle or re-signing after the copy.

3.3 Keypair discipline

One EdDSA keypair, three places, all of which must agree:

Place Used by
Info.plist SUPublicEDKey (0XdLbbuO…) Every shipped app, to verify updates
Maintainer's login keychain release.sh local signing (no flag)
GitHub secret SPARKLE_ED_PRIVATE_KEY CI signing

If the signing key and SUPublicEDKey diverge, everything looks fine — the DMG signs without error — but every user's update fails signature verification. Sanity check when in doubt: sign_update --verify <dmg> <sig> against the Info.plist public key.

3.4 Appcast push to protected main — RELEASE_TOKEN

The workflow's last step commits appcast.xml and pushes to main, which requires the test status check. The default GITHUB_TOKEN / github-actions[bot] is not an admin, so that push is rejected with GH006 … protected branch hook declined. Branch protection has enforce_admins: false, so an admin's push bypasses the check — hence the fine-grained admin PAT in the RELEASE_TOKEN secret (Contents: read/write), set as the token: on the checkout step, not on the push. That placement matters: actions/checkout persists an http.<host>.extraheader credential that overrides inline-URL credentials, so rewriting the push URL would keep pushing with the bot token anyway.

RELEASE_TOKEN expires 2027-06-27. Rotate it before then or every release fails at the appcast push while the GitHub Release itself succeeds (a confusing half-shipped state — see §4 post-flight).

3.5 create-dmg quirks

  • It's the npm package create-dmg (sindresorhus), installed via npm install --global create-dmg. Homebrew's create-dmg is a different tool with an incompatible CLI. Requires Node ≥ 20 (CI pins it).
  • It exits 2 when it can't Developer-ID-sign/notarize the image (Edmund ships ad-hoc) but still produces the .dmg. Both scripts run it with || true and then verify the file exists, failing loudly only if no dmg was produced. Don't remove the || true; don't trust the exit code.
  • Output is named "Edmund <version>.dmg" — with a space. Both scripts rename to Edmund-<version>.dmg (hyphen), which is the name the appcast enclosure URL expects. If a rename is skipped, the release asset URL 404s for every updater.

4. Pre-flight and post-flight

Pre-flight (distilled from misc/before-you-release.md — read it too)

  • swift test green on main, not just the branch; git status clean.
  • No debug flags / launch args left on (repro drivers, verbose tracing — see edmund-config-and-flags, ARCHITECTURE §8).
  • Visual sanity: build and screencapture the editor in light and dark mode; click through everything the CHANGELOG claims ("fixed X" → actually reproduce X and confirm).
  • CHANGELOG.md has ## [x.y.z] — YYYY-MM-DD for this release and the version matches Info.plist (CFBundleShortVersionString); ### subheads, not ## (§2).
  • CFBundleVersion bumped (monotonic int).
  • RELEASE_TOKEN not expired (2027-06-27).
  • Local path only: gh auth status ok; keychain key matches SUPublicEDKey (§3.3).

Post-flight

  • GitHub Release vX.Y.Z exists with the right notes and the Edmund-<v>.dmg asset (hyphenated name).
  • appcast.xml on main got the new <item> — with <description>, correct sparkle:version (= CFBundleVersion) and enclosure URL.
  • Nothing to do for user prompts: Sparkle checks roughly daily; existing users see the update within ~24 h. Don't panic if it isn't instant.

If the Release exists but the appcast commit is missing, the release half-shipped (usually §3.4). Fix the token, then add the <item> manually or re-run the job.


5. Gatekeeper story (why users see "damaged")

Edmund is ad-hoc signed, not notarized (no $99/yr Developer ID). First launch of a downloaded copy trips Gatekeeper with the "app is damaged" dialog. This is expected; the app is fine. The README documents both workarounds (verified, README ~line 53):

  • xattr -dr com.apple.quarantine /Applications/Edmund.app, or
  • right-click → Open.

Known upgrade path (open/candidate, not scheduled): Developer ID certificate

  • notarization would remove the prompt entirely and also clean up the non-strict-sealing compromise in §3.2. Don't promise it in user-facing text.

6. Operating the app

Launching

open Edmund.app foregrounds a running instance instead of relaunching — you'll stare at old code. And never pkill -x edmd blindly: the maintainer's own Edmund session may be running (the Mach-O is edmd for both). Check first (pgrep -x edmd), kill only PIDs you started, or launch the binary directly: build/Edmund.app/Contents/MacOS/edmd file.md &. Full launch / stale-build / screencapture mechanics: edmund-build-and-env.

Logs — ~/.edmund/logs/edmund-YYYY-MM-DD.log

  • One file per day, human-readable lines tagged LEVEL [category] (categories: app, document, io, render, compose, selection, lazy, callout, edit — grep by concern).
  • Controlled by Settings ▸ Advanced ▸ Diagnostics ("Save diagnostic logs"). The toggle defaults OFF (AppSettings.diagnosticLogging defaults false) — i.e. opt-in in the shipped app, despite Log.swift's header comment calling it "always-on (opt-out)"; the code is the truth. (The UserDefaults keys are named settings.general.* for legacy reasons; the UI lives in Advanced.)
  • Retention picker ("Clear logs after:") next to the toggle; a separate "Verbose editor tracing" opt-in gates keystroke-level trace lines — leave off except during repros (edmund-live-repro-and-diagnostics).
  • Release builds write info and up; DEBUG builds also write debug.
  • Logs may contain document text; they never leave the machine.

Crash reports — ~/Library/Logs/DiagnosticReports/edmd-*.ips

  • macOS names crash reports after the Mach-O executable: look for edmd-<timestamp>.ips, not "Edmund-…".
  • Uploading is opt-in and currently INERT. The Settings toggle is commented out in Sources/edmd/Settings/AdvancedSettingsView.swift ("dormant until the receiving server exists"), and CrashReporter.reportingEndpoint is a placeholder (https://REPLACE-ME.invalid/crash). Nothing is ever sent in shipped builds. Don't tell users crash reporting exists; don't uncomment the toggle without a real server. Code: Sources/EdmundCore/Diagnostics/CrashReporter.swift.
  • Reading works only because Edmund is not sandboxed; adopting App Sandbox would force a MetricKit rewrite (noted in CrashReporter's header).
  • Triage of a user's .ips: it's JSON — a one-line metadata header, then the report body. Look at exception (type/signal), faultingThread, and walk that thread's frames for images named edmd or Sparkle. Ad-hoc builds ship no dSYM, so expect addresses rather than symbol names for app frames; correlate with ~/.edmund/logs from the same timestamp instead. .ips files embed the user's home path and device model — treat as mildly personal data.

Update mechanics (user side)

  • SUFeedURL = https://raw.githubusercontent.com/I7T5/Edmund/main/appcast.xml — the raw-GitHub URL of the checked-in appcast; committing to main is publishing.
  • SUEnableAutomaticChecks is true; no custom interval is set, so Sparkle uses its default ~24 h cadence (plus a check on launch).
  • Sparkle downloads the DMG enclosure, verifies EdDSA against SUPublicEDKey, mounts the DMG, re-validates the Apple code signature (§3.2), installs.

7. Versioning & appcast conventions

Thing Convention Current (2026-07-05)
Git tag vX.Y.Z v0.1.3 pending its tag; last released 0.1.2
CFBundleShortVersionString SemVer marketing version 0.1.3
CFBundleVersion monotonic integer, +1 per release 4
CHANGELOG Keep a Changelog 1.1.0, ## [x.y.z] — YYYY-MM-DD, ### subheads, --- separators

appcast.xml (checked into repo root): RSS 2.0 with the sparkle: namespace. One <channel> (title/link/description/language) containing one <item> per release. Items are inserted before </channel>, so the file reads oldest → newest; Sparkle doesn't care about order — it picks by version. Per item:

<item>
    <title>Edmund 0.1.2</title>
    <pubDate>Fri, 03 Jul 2026 19:03:59 +0000</pubDate>
    <description><![CDATA[ …HTML from changelog-to-html.py… ]]></description>
    <enclosure url="https://github.com/I7T5/Edmund/releases/download/v0.1.2/Edmund-0.1.2.dmg"
               sparkle:version="3"                <!-- CFBundleVersion -->
               sparkle:shortVersionString="0.1.2" <!-- marketing version -->
               sparkle:edSignature="…"
               length="7608991"
               type="application/x-apple-diskimage"/>
</item>

<description> is optional (omitted when the CHANGELOG section is missing).


8. Roadmap context (for release-content judgment)

  • Edmund is in beta (0.1.x line, first public release 0.1.0 on 2026-06-27). Small, frequent releases.
  • v0.2.0 goal: "Polished editing experience" (misc/backlog.md § Now).
  • Priority ordering: Marketing = Bugs >= UI/UX > Features — when deciding what makes a release, bug fixes and polish beat new features.
  • Long-range plan (v1.0 = onboarding + full GFM + extensions groundwork): docs/ROADMAP.md; working backlog with per-bug detail: misc/backlog.md.

Provenance and maintenance

Written 2026-07-05 from direct reads of: .github/workflows/release.yml, scripts/release.sh, scripts/build-app.sh, scripts/changelog-to-html.py, appcast.xml, CHANGELOG.md, Info.plist, README.md, docs/ARCHITECTURE.md (§8, §13), misc/how-to-release.md, misc/before-you-release.md, docs/ROADMAP.md, misc/backlog.md, Sources/EdmundCore/Diagnostics/CrashReporter.swift, Sources/EdmundCore/Diagnostics/Log.swift, Sources/edmd/Settings/AdvancedSettingsView.swift, Sources/edmd/Settings/AppSettings.swift.

Known stale docs at time of writing: misc/how-to-release.md (zip vs DMG, §1); Log.swift header ("always-on (opt-out)" vs the actual default-off toggle, §6). Minor oddity, deliberate: build-app.sh signs with --identifier "com.i7t5.edmd" while the bundle id is com.i7t5.edmund.

Re-verify when any of these change: release.yml step names or secrets, build-app.sh signing order, the CHANGELOG header format (the awk regex in two places must match it), SUFeedURL, RELEASE_TOKEN rotation (hard deadline 2027-06-27), notarization status, or the crash-report server going live (which un-inerts §6's crash uploading and this skill's wording).

识别Edmund Markdown编辑器的前沿技术难题,聚焦视口稳定性与光标完整性。提供SOTA缺陷分析、项目资产匹配及具体实验步骤,用于评估创新价值并设定可证伪里程碑,辅助决策是否启动高难度研发。
选择下一个重大技术攻关方向 评估激进想法的可行性 询问如何超越当前技术水平
.claude/skills/edmund-research-frontier/SKILL.md
npx skills add I7T5/Edmund --skill edmund-research-frontier -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-research-frontier",
    "description": "Open problems where the Edmund Markdown editor could advance the state of the art — product-first, everything labeled candidate\/open, nothing proven. Load when picking the next big problem, evaluating whether an ambitious idea is worth starting, or answering \"what would move this project beyond state of the art\". Each frontier: why current SOTA fails, Edmund's specific asset, the first three concrete steps IN THIS REPO, and a falsifiable \"you have a result when…\" milestone. Not for running an accepted investigation (edmund-caret-integrity-campaign), the method of proof (edmund-research-methodology), or shipping the change (edmund-change-control)."
}

Edmund research frontier

Where Edmund could go past the state of the art. Ambition is product-first: "the CotEditor of Markdown editors" (README). Advanced TextKit 2 techniques are means, not ends — a technique is worth pursuing when it makes the product better, and it becomes publishable as a side effect.

Everything here is candidate / open. Nothing is proven. Each item routes its proof through edmund-research-methodology (hypothesis predicts numbers) and its changes through edmund-change-control. Verified 2026-07-05 against the repo; assets cited are real, outcomes are not.


Frontier 1 — Viewport-stable TextKit 2 at scale (>100k UTF-16)

Why SOTA fails: TK2 lays out only near the viewport; off-screen fragment heights are estimates corrected as layout reaches them. This makes the scroller jump and scroll-to-target miss in every TK2 app, including TextEdit — a widely documented limitation. Above fullLayoutMaxLength (100k) Edmund enters this regime.

Edmund's asset: the mitigations already in TextView/scheduleFullLayoutSettle, preservingViewportAnchor, repairContentAboveOrigin, centerViewportOnCaret re-measure, and the diff-based undo restore that avoids resetting fragments to estimates; plus the ScrollStabilityTests / HeightStabilityTests harnesses. (Note: repairContentAboveOrigin is mitigated-unconfirmed live per docs/investigations/viewport-glitch-investigation.md — a real result here would also retire that honest gap.)

First three steps in this repo:

  1. Build a large-doc fixture (makeLargeMarkdown in TestHelpers.swift) >100k and a scripted scroll-accuracy metric (ReproScript caret + a logsel-style position dump, or extend PerfHarnessTests).
  2. Quantify the estimate-error distribution: for N scroll-to-target operations, record predicted vs actual landing pixel offset.
  3. Prototype persistent per-fragment height caching across the settle (or across sessions) and re-measure the same distribution.

You have a result when: scroll-to-target lands within a stated pixel budget on a 1 MB document, measured by script, with zero repairing content above origin events across a scroll soak — reproducibly.


Frontier 2 — Caret integrity by construction

Why SOTA fails: every NSTextView consumer depends on didChangeText pairing that AppKit itself violates (the drag-move bypass). The delete-drift class is the symptom of building sync on a callback contract AppKit doesn't keep.

Edmund's asset: the heal machinery, the pendingEdit model, six documented rounds of mechanism knowledge, and the ReproScript + soak methodology. The campaign skill runs individual rounds reactively; this frontier is the structural endgame — eliminate the class.

First three steps:

  1. Inventory every storage-mutation entry point (grep replaceCharacters, setAttributes, the edit-flow paths) and tabulate which currently rely on a callback firing.
  2. Design a sync layer keyed on a storage-version counter that reconciles rawSource regardless of which callbacks fired (not a new guard per path).
  3. Falsify it against all historical .repro scripts plus a new randomized bypass-fuzzer script.

You have a result when: all historical .repro scripts and a randomized bypass soak stay green with the callback-pairing assumption deleted from the code. Candidate, big — likely a multi-PR redesign; do not start without the methodology skill's evidence bar in front of you.


Frontier 3 — The 10 MB class (performance headroom)

Why it matters: README claims "~1–2 MB files"; native-with-no-Electron is the differentiator, so headroom is a product claim, not vanity.

Edmund's asset: viewport-based lazy styling, the idle drain, incremental reparse (pendingEdit window), PerfHarnessTests.

First three steps:

  1. Extend PerfHarnessTests with 5/10 MB fixtures (makeLargeMarkdown).
  2. Profile the block-parse and restyle hot paths (Log.measure single-line durations are already in place).
  3. Set explicit latency budgets for open, first-paint, and per-keystroke restyle at 10 MB.

You have a result when: open + steady-state typing latency on a 10 MB file meets a stated budget, measured by the harness (not hand-timed).


Frontier 4 — A native extensions API

Why SOTA fails: Obsidian/VS Code plugin ecosystems are Electron; there is no strong precedent for a native, safe, fast extension surface for live-preview Markdown on macOS. ROADMAP v1.0.0 lists "Extensions API, documentations, primitive marketplace" and flags Advanced Syntax Highlighting / Advanced Math as "official extension" candidates.

Edmund's asset: the custom-parser seam (Parsing/SyntaxHighlighter+CustomParsers.swift), the BlockKind/styleBlock architecture, and already-modular opt-in syntax (math, Obsidian syntax).

First three steps:

  1. Catalog which existing features could be re-implemented as extensions (dogfood: callouts? highlight? wikilinks?) — this defines the real extension points.
  2. Define the minimal seam: block parser? inline parser? theme hook? Draw the line at what the custom-parser architecture already supports.
  3. Spike one official extension behind a flag and measure restyle cost vs the built-in.

You have a result when: one built-in syntax feature runs as an extension with no measurable restyle regression against the built-in baseline.


Frontier 5 — Accessibility / RTL / localization as a differentiator

Why it matters: native apps can excel where Electron editors are weak; ROADMAP v1.x lists Localization, RTL, Accessibility. Locale-aware content width already ships as precedent that the pipeline can be locale-sensitive.

Edmund's asset: the attribute-only pipeline (structure is in the string, not in inserted characters), the existing locale-aware content-width path.

First three steps:

  1. VoiceOver audit of EditorTextView's custom drawing — does the accessibility tree expose headings/lists/callouts, given they're drawn as decorations?
  2. Test RTL behavior of the attribute-only styling on a right-to-left document.
  3. Scriptable a11y check (structure read-out) as a regression guard.

You have a result when: a scripted VoiceOver audit reads document structure (headings, list items, callouts) correctly on a mixed document.


How to start one

  1. Predict numbers first (edmund-research-methodology §2) — every milestone above is a number, not a vibe.
  2. Instrument to make the current failure/limit cheap to measure.
  3. Prototype behind a flag; measure predicted vs observed.
  4. Route changes through edmund-change-control (branch, tests, no auto-push).
  5. When the first milestone lands, the item graduates to misc/backlog.md or docs/ROADMAP.md and stops being a frontier.

What NOT to start (no current asset)

  • iOS / iPadOS port — explicitly TBD in ROADMAP; no shared UI layer today.
  • Collaborative / real-time editing — zero repo support (no CRDT, no sync, single NSDocument model). Would be a new product, not a frontier of this one.
  • Anything that requires breaking an invariant (storage == rawSource; TextKit 2 only) to work — that's not a frontier, it's a rewrite (edmund-architecture-contract).

When NOT to use this skill

  • Running an accepted investigation (a known bug) → edmund-caret-integrity-campaign / edmund-debugging-playbook.
  • The method of turning a hunch into proof → edmund-research-methodology.
  • Whether a public claim is allowed yet → edmund-external-positioning.
  • Shipping the change → edmund-change-control.

Provenance and maintenance

Verified 2026-07-05. Assets exist; outcomes are unproven by definition — never quote a milestone here as achieved.

grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
grep -rn 'repairContentAboveOrigin\|preservingViewportAnchor' Sources/EdmundCore/TextView/
ls Sources/EdmundCore/Parsing/SyntaxHighlighter+CustomParsers.swift
grep -n 'Extensions API\|RTL\|Localization\|iPadOS' docs/ROADMAP.md
grep -rn 'func makeLargeMarkdown' Tests/EdmundTests/TestHelpers.swift

When an item's milestone lands, move it to ROADMAP/backlog and delete it here — a frontier list that keeps solved problems is lying.

指导如何将假设转化为可交付的修复方案。核心包括:证据需解释所有现象及反例;实验前需预测具体数值;采用追溯考古和低成本观测等分析食谱,通过主动证伪确保结论可信,避免仅凭推理上线。
形成关于bug机制的假设时 评估调查结论的可信度 决定修复是否已得到证明 将想法转化为已接受的变更
.claude/skills/edmund-research-methodology/SKILL.md
npx skills add I7T5/Edmund --skill edmund-research-methodology -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-research-methodology",
    "description": "The discipline that turns a hunch into an accepted result in the Edmund Markdown editor — the evidence bar, hypothesis-predicts-numbers, the first-principles analysis recipes, the idea lifecycle, and where good ideas historically came from. Load when forming a hypothesis about a bug's mechanism, evaluating whether an investigation's conclusion is trustworthy, deciding if a fix is actually proven, or turning an idea into an accepted change. This skill also absorbs the proof-and-analysis toolkit (prove it, don't just install it). Not the caret-integrity campaign itself (edmund-caret-integrity-campaign), not the repro tooling (edmund-live-repro-and-diagnostics)."
}

Edmund research methodology

How a hunch becomes something you can ship without it coming back. Every method here is grounded in a real episode from this repo's history — the discipline was paid for in the six delete-drift rounds and the viewport work.

Verified 2026-07-05 against docs/investigations/delete-drift-investigation.md, docs/investigations/viewport-glitch-investigation.md, and docs/dev-guides/live-repro-guide.md.


1. The evidence bar

A mechanism is accepted only when it clears both bars:

  1. It explains ALL observations, including the negatives. Not just "the caret drifts" but why headless tests pass, why it's intermittent, why it appears minutes after the trigger. A mechanism that explains the symptom but not the negatives is incomplete — and incomplete mechanisms come back.
  2. It survives assigned adversarial refutation. Before shipping, actively try to falsify the supposed fix: run the frozen repro against it. Rounds 1–5 of delete-drift shipped on reasoning and all came back; round 6's first fix candidate "worked by reasoning" and failed in the repro within a minute (docs/investigations/delete-drift-investigation.md round 6, "the iteration that proved its shape").

Corollary: headless-green is not the bar for live-input bugs — the round-6 regression test passes with and without the fix. See edmund-validation-and-qa for the evidence hierarchy.


2. Hypothesis predicts numbers BEFORE running

State the expected observable before the experiment. An experiment without a predicted number is a fishing trip.

  • Worked example: round-6 logsel predicted 321 on the broken build vs 290 on the fixed build — a specific number, stated before the run, that the repro then confirmed every time.
  • Determinism as a prediction: a soak's final rawLen must be byte-identical across runs; predict it, then check it.

If you can't name the number your hypothesis predicts, you don't understand the mechanism well enough to test it yet — go back to instrumentation (§3b).


3. The analysis recipes (prove it, don't just install it)

Each: when to use, the steps, and the episode that earned it.

3a. Trace archaeology — walk BACKWARDS

When: any symptom with diagnostics on. Steps: find the first bad line, then read upward/earlier, not forward from the symptom. Episode: the round-6 drift at 22:13 was armed by a bypass at 22:11:57 — 80 seconds and dozens of healthy edits earlier. Source: docs/dev-guides/live-repro-guide.md §2.

3b. Make the failure cheap to observe before reasoning about fixes

When: you're tempted to guess. Steps: add one breadcrumb (a call stack, a state dump) behind Log.shouldTrace and ship it; reproduce; let the log name the culprit. Episode: traceSelectionOrigin named _fixSelectionAfterChange in a single run after five rounds of guessing. This is the meta-lesson: time spent making the failure cheap to observe beats time spent reasoning about the fix.

3c. The fidelity ladder as an inference tool

When: a repro fails. Steps: a repro that fails at level N but succeeds at N+1 localizes the mechanism to what N lacks. Episode: delete-drift not reproducing in a windowed unit test (level 2) told the team it needed deferred/queued AppKit state → pointed straight at level-3 in-process replay and event-ordering hypotheses. Source: docs/dev-guides/live-repro-guide.md §1.

3d. Invariant auditing

When: the behavior looks impossible. Steps: find which invariant transiently brokestorage == rawSource during IME composition; didChangeText pairing during a drag-move; grep the guard inventory (hasMarkedText, bypass checks). Lock it with an equivalence/fuzz oracle: RecomposeEquivalenceTests (assertMatchesFullRecomposeOracle — incremental restyle must equal a full restyle) and IncrementalParseFuzzTests (random edits keep window-reparse == full reparse).

3e. Exact-sequence replication

When: simulating an AppKit-internal path. Steps: replay its real call sequence, pinned from a stack trace, not an approximation of its effect. Episode: bypassdelete reproduces shouldChangeTextreplaceCharacters with no didChangeText verbatim — an approximation would have hidden the bug.

3f. Differential measurement over eyeballing

When: any visual or binary claim. Steps: screencapture pixel measurement for visuals (edmund-live-repro-and-diagnostics §5); shasum before/after for "did the binary actually change"; predicted-vs-observed tables for numbers. Episode: the maintainer's rule — when told "balance the padding", measure the top vs bottom pad in pixels, don't judge by eye.


4. The idea lifecycle

How an idea moves from hunch to settled (or to a documented dead end):

hunch
  → cheap instrumentation / discriminating experiment (§3b, §3c)
  → investigation-doc entry (docs/<topic>-investigation.md, round structure)
  → frozen deterministic repro
  → fix on its own fix/ branch, with test + soak (edmund-validation-and-qa)
  → ARCHITECTURE §8 gotcha entry in the SAME PR (if an invariant changed)
  → settled status in the chronicle (edmund-failure-archaeology)

Retirement path for ideas that fail: a documented dead end in the investigation doc with a "do not retry" note and why (see the round chronicle and the viewport doc's "Verification limits (honest gaps)" section — mitigations that are real but unconfirmed live are labeled so, not oversold).

Where larger ideas are tracked: docs/ROADMAP.md (versioned feature plan) vs misc/backlog.md (near-term working list; priority order **Marketing = Bugs

= UI/UX > Features**). A frontier idea graduates onto one of these when its first milestone lands (edmund-research-frontier).


5. Where good ideas historically came from

Mine these seams first — they've paid out repeatedly:

  • Making failures observable. The diagnostics accumulated one breadcrumb at a time; each named a mechanism the previous round guessed at.
  • Reading Apple's actual behavior, not the documented ideal. Sparkle's update validation is non-strict (SecStaticCodeCheckValidityWithErrors) — discovered by testing the real API, which unblocked the whole update pipeline. NSWindow.sendEvent as the pre-toolbar funnel solved the toolbar right-click after menu/rightMouseDown/gestures all failed.
  • Prior art consulted before inventing. nodes-app/swift-markdown-engine (ARCHITECTURE §14) solves the same TK2 live-preview problems with different trade-offs — a comparison point and technique source. MarkEdit/CotEditor as product references (ROADMAP).
  • Field reports with logs + movs. misc/bug-repros/ holds the maintainer's screen recordings and trace logs — the round-4 drag-move mechanism came straight out of one such trace.

6. Anti-patterns (each with its historical cost)

Anti-pattern Cost paid
Fix at symptom time Patches the wrong instant — symptom armed minutes earlier (round 6)
Trust headless green for a live-input bug Round-6 test passes with and without the fix
Stack speculative fixes Rounds 1–5 each "fixed" it; each came back
Skip document reconstruction Geometry/block-kind-dependent bugs don't fire on "hello world"
Trust a possibly-stale binary SwiftPM prints Build complete! without relinking → wrong conclusions
Declare victory without a soak One clean repro misses armed-state / degradation bugs

When NOT to use this skill

  • Actually running the caret investigation step by step → edmund-caret-integrity-campaign.
  • The repro drivers and trace decoding → edmund-live-repro-and-diagnostics.
  • What evidence a change type requires + the test suite → edmund-validation-and-qa.
  • The settled history you're checking against → edmund-failure-archaeology.
  • Picking the next big problem → edmund-research-frontier.

Provenance and maintenance

Verified 2026-07-05.

grep -nE '^## Round|honest gaps|Verification limits' docs/investigations/delete-drift-investigation.md docs/investigations/viewport-glitch-investigation.md
grep -n '321\|290' docs/investigations/delete-drift-investigation.md         # the predicted numbers (§2)
grep -rn 'assertMatchesFullRecomposeOracle' Tests/EdmundTests/
ls misc/bug-repros/                                            # field-evidence seam (§5)

The methods are stable; the episodes they cite are the drift risk — if a new round rewrites the delete-drift narrative, refresh the worked examples here.

指导在Edmund编辑器中编写测试与验证修复。涵盖Swift Testing框架、证据层级(从单元测试到视觉QA)、测试套件结构及不同变更类型所需的验证标准,确保代码质量。
编写或调整测试用例 判断修复是否经过验证 添加回归或黄金文件测试 解释测试套件结果
.claude/skills/edmund-validation-and-qa/SKILL.md
npx skills add I7T5/Edmund --skill edmund-validation-and-qa -g -y
SKILL.md
Frontmatter
{
    "name": "edmund-validation-and-qa",
    "description": "What counts as EVIDENCE in the Edmund Markdown editor, and how to add tests. Load when writing or adjusting tests, deciding what proof a fix needs before it can ship, judging whether a change is actually \"verified\", interpreting the test suite, or adding a golden\/regression check. Covers the evidence hierarchy (headless is necessary but not sufficient for live-input bugs), the test-suite map, the Swift Testing helpers, how to add a test, the golden inventory, visual QA, and performance evidence. Not the gating rules themselves (edmund-change-control) or how to run the live repro (edmund-live-repro-and-diagnostics)."
}

Edmund validation & QA

The discipline of proof. The central lesson: a green headless test is necessary but not sufficient for the bug classes that cost the most time here. Know which evidence each change actually requires.

Framework: Swift Testing (import Testing, @Test, #expect, #require) — NOT XCTest. Verified 2026-07-05: 810 @Test cases across Tests/EdmundTests/.


1. The evidence hierarchy

Ordered weakest → strongest. The rule at the bottom names which class each change type requires.

Class What it proves Blind spot
(a) Headless unit test (makeEditor()) model/parse/style/undo logic Cannot exercise deferred AppKit machinery — runs it synchronously
(b) Windowed unit test (real NSWindow + NSScrollView, real deleteBackward) + layout, viewport, first responder still not real event-loop pacing / IME / drag
(c) Frozen live ReproScript repro (exact script + document) the live input/timing mechanism needs a debug build + ~1 min/run
(d) Soak script green across 4–5 cycles, byte-identical final rawLen armed-state + determinism slow
(e) Screencapture pixel measurement anything that DRAWS manual

The trap that defines this skill: the delete-drift round-6 regression test passes with AND without the fix — the harness runs the queued selection fixup synchronously, so headless can't see the bug. For caret/IME/drag/viewport-timing bugs, class (a) is not evidence of a fix; you need (c)+(d).

Required evidence by change type (gates enforced in edmund-change-control):

Change Requires
Pure logic (parse/style/model) (a)
Viewport/lazy-layout (b), often (e)
Anything that draws (a where testable) + (e) in light AND dark
Edit-pipeline / selection / IME / undo (a) to lock the headless contract + (c) frozen repro + (d) soak
Release see edmund-release-and-operate

2. Test-suite anatomy

Run: swift test (full suite; ARCHITECTURE cites ~750+ tests ≈10s — 810 @Test cases as of 2026-07-05). One suite: swift test --filter <Suite>. swift test also runs automatically as a Stop hook (.claude/settings.json) at the end of any turn touching code, so failures surface before you commit.

Map of Tests/EdmundTests/ (what the files actually cover):

  • Parsing: BlockParserTests, SyntaxHighlighterTests, IncrementalParseFuzzTests, PendingEditTests, EscapeRenderingTests, HTMLTagRenderingTests, EmojiRenderingTests, LineEndingTests.
  • Rendering / styling: BlockStylingTests, CalloutRenderingTests, CalloutTests, CommentRenderingTests, NestedBlockStylingTests, InlineStylingTests, MathRenderingTests, ImageRenderingTests, CodeHighlighterTests, FootnoteTests, WikiLinkTests, TableAlignmentTests, RenderingRegressionTests.
  • Editor behavior: EditorIndentationTests, ListContinuationTests, BlockquoteContinuationTests, BlockquoteDeletionTests, FormattingTests, ActiveBulletMarkerTests, NewListItemAlignmentTests.
  • Edit-pipeline integrity (the costly class): BypassedEditSyncTests, MarkedTextDesyncTests, WrappedParagraphCaretTests, EditorDiagnosticsTests, UnmatchedDebugTests, InternationalInputTests.
  • Viewport / layout / undo: LazyRenderingTests, ScrollStabilityTests, HeightStabilityTests, TypewriterCenteringTests, UndoRedoViewportTests, EditorUndoTests, RecomposeTests, RecomposeEquivalenceTests.
  • Export / read mode: HTMLRendererTests, DocumentHTMLTests, ReadModeWebViewTests, HTMLThemeTests, EditorThemeTests, ViewModeTests, ContentWidthTests.
  • Infra / harness: LogTests, CrashReporterTests, PerfHarnessTests, StatusBarPrefsTests, FileIntegrationTests, EditorDocumentTests, TestHelpers.swift. _RenderDump.swift / _RenderEdit.swift are local dev tools (gitignored intent) that dump Read-mode HTML / edit output for tmp/sample.md — not part of the assertion suite.

Two invariant-guarding patterns worth copying:

  • Recompose equivalence (RecomposeEquivalenceTests, helper assertMatchesFullRecomposeOracle): an incremental restyle must produce the same result as a full recompose. This is the safety net for the lazy / incremental styling paths.
  • Incremental-parse fuzz (IncrementalParseFuzzTests): random edits must keep the parser's window-reparse consistent with a full reparse.

3. Test helpers (TestHelpers.swift)

@MainActor helpers you build on (verified exports):

Helper Use
makeEditor() an EditorTextView on the real TK2 chain (mirrors Document.makeWindowControllers)
ensureFullLayout(...) force layout so geometry is real, not estimated
type(...), paste(...), pressEnter(), pressBackspace() drive edits
activateBlock(...) move the caret / active block
displayText(...), attrs(...), font(...), fgColor(...) inspect styled output
isHidden / isInvisible / isDimmed assert delimiter hiding
blockDecoration(...), textBlockDifference(...) inspect decorations / catch TK1-reverting table attrs
expectedFullComposition(...), drainAllStyling(), assertMatchesFullRecomposeOracle(...) equivalence-oracle checks
makeLargeMarkdown(...), sentence(...) build big fixtures for perf/viewport

styleBlock(_:cursorPosition:) is a method on EditorTextView (Rendering/EditorTextView+Rendering.swift), called from tests — it renders one block to an attributed string.


4. How to add a test

Skeleton (Swift Testing, @MainActor because the editor is main-thread):

import Testing
import AppKit
@testable import EdmundCore

@MainActor
@Test func deletingAtCalloutBottomKeepsInvariant() {
    let editor = makeEditor()
    editor.loadRawSource("> [!note]\n> body\n")
    drainAllStyling()
    // ... drive the edit ...
    #expect(editor.rawSource == editor.string)   // storage == rawSource invariant
}

Rules:

  1. Every bug fix ships with a test even if it can't discriminate a live-only mechanism — it still locks the headless contract so a future refactor can't silently re-break the model half. For the live half, keep the frozen .repro script alongside (edmund-live-repro-and-diagnostics).
  2. Assert the invariant where you can (rawSource == string), not just the surface symptom — that's what BypassedEditSyncTests / MarkedTextDesyncTests do.
  3. Name the test for the behavior/bug, put edit-pipeline repros next to their siblings (the *Desync* / *Bypassed* / *CaretTests families).
  4. New drawing behavior additionally needs a screencapture check (§6).

5. Golden / certified inventory

  • RenderingRegressionTests + RecomposeEquivalenceTests are the closest thing to golden checks — they pin styled output and incremental-vs-full equivalence.
  • test-files/*.md (callout.md, decorations.md, math.md, menu.md, test.md) are the user's manual test corpus — hand-testing fodder. Do not rewrite them to fit an automated test; build your own fixtures (helpers in §3, or a scratch dir).
  • misc/bug-repros/ holds field evidence (.mov screen recordings + .log traces) for open bugs — reference these when reproducing, don't delete them.

6. Visual QA

Anything that draws is verified by screencapture pixel measurement, in light AND dark mode (per misc/before-you-release.md), never by eyeballing headless layout. Method + scripts: edmund-live-repro-and-diagnostics §5. Headless layout tests (HeightStabilityTests, ScrollStabilityTests) check geometry numbers but cannot confirm the pixels are right.


7. Flakiness & CI

  • A fix/flaky-math-test branch exists in history — math rendering has shown timing flakiness; if a math test flakes, check that branch's approach before inventing a new one (verify: git log --oneline --all -- '*Math*').
  • CI: .github/workflows/ci.yml on macos-14, latest-stable Xcode, SPM cache keyed on Package.resolved, concurrency: cancel-in-progress (private-repo macOS minutes bill 10×). CI runs the same swift test.

8. Performance evidence

  • PerfHarnessTests measures the hot paths; makeLargeMarkdown builds big fixtures. Use it (don't hand-time) for any perf claim.
  • The README claim "handles ~1–2MB files" and fullLayoutMaxLength = 100_000 UTF-16 (EditorTextView.swift:80) mark the boundary between the full-layout regime (≤100k, geometry is real) and the estimate regime (>100k, viewport glitches possible). A perf/viewport claim must state which regime it was measured in.

When NOT to use this skill

  • The gate/branch/commit rules → edmund-change-control.
  • Running the live repro or measuring pixels → edmund-live-repro-and-diagnostics.
  • The caret-integrity investigation end to end → edmund-caret-integrity-campaign.
  • Why a mechanism works → textkit2-appkit-reference / edmund-architecture-contract.
  • Release verification → edmund-release-and-operate.

Provenance and maintenance

Verified 2026-07-05.

grep -rh '@Test' Tests/EdmundTests/*.swift | wc -l         # ~810 cases
grep -c 'import Testing' Tests/EdmundTests/TestHelpers.swift # confirms Swift Testing, not XCTest
grep -oE 'func [a-zA-Z]+' Tests/EdmundTests/TestHelpers.swift # helper inventory (§3)
ls Tests/EdmundTests/                                        # suite map (§2)
grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift

If a helper in §3 no longer greps it was renamed; update §3 and any test skeletons. Re-derive the suite map from ls if files are added/removed.

AppKit TextKit 2 在 Edmund 编辑器中的理论参考。涵盖对象模型、视口布局机制及高度估算导致的滚动问题,提供具体代码指针与缓解策略,适用于处理布局、选择、IME 等文本系统行为异常的场景。
处理文本布局或选择行为异常 遇到 IME 或撤销操作问题 TextKit 2 出现意外行为 涉及 NSTextLayoutManager 等核心概念
.claude/skills/textkit2-appkit-reference/SKILL.md
npx skills add I7T5/Edmund --skill textkit2-appkit-reference -g -y
SKILL.md
Frontmatter
{
    "name": "textkit2-appkit-reference",
    "description": "Domain-theory pack for the AppKit text system as it applies to the Edmund Markdown editor. Load when working on layout, selection, IME, undo, drag, or eventing behavior; when TextKit 2 or NSTextView does something surprising; or when terms like NSTextLayoutManager, layout fragment, marked text, queued selection fixup, responder chain, or sendEvent appear and you lack AppKit text-system background. Explains the mechanisms the invariants and gotchas are built on. Not the invariants themselves (edmund-architecture-contract), not a triage table (edmund-debugging-playbook), not the repro drivers (edmund-live-repro-and-diagnostics)."
}

TextKit 2 / AppKit reference (as it applies to Edmund)

The background a mid-level engineer or Sonnet-class model usually lacks. Each concept: brief theory, then where it bites in Edmund with a verified file pointer. This is not a textbook — it is only the parts that matter here.

Facts checked against source 2026-07-05. Items labeled (background) are general AppKit/TextKit behavior grounded in Apple's documentation, not directly grep-able in this repo.


1. The TextKit 2 object model (background + repo)

TextKit 2 replaced the TextKit 1 NSLayoutManager stack. The players:

  • NSTextContentStorage — owns the backing string + attributes (the model).
  • NSTextLayoutManager — lays text out (the TK2 analogue of the old layout manager).
  • NSTextLayoutFragment — one laid-out chunk (≈ a paragraph); has a real geometric frame only once laid out.
  • NSTextElement / NSTextParagraph — the model-side elements fragments render.

Viewport-based layout is the headline difference: TK2 lays out only the content near the visible viewport, not the whole document. That is what makes big documents fast — and it is the root of most viewport pain (§2).

Where it bites: Edmund subclasses the fragment as DecoratedTextLayoutFragment (EditorTextView+TextKit2.swift) to draw callout boxes, bars, and overlays.


2. Height ESTIMATES — the master cause of viewport glitches

A fragment that has not been laid out yet has an estimated height, not a real one; the total document height is the sum of real + estimated fragment heights. As layout reaches a fragment, its estimate is replaced by the true value and everything below shifts. Consequences: the scroller thumb jumps, and "scroll to offset Y" lands wrong because Y was computed from estimates. This is a widely documented TK2 limitation — even TextEdit shows it (background).

Where it bites / Edmund mitigations (verify names by grep; all in TextView/):

  • fullLayoutMaxLength = 100_000 (EditorTextView.swift:80): documents ≤ 100k UTF-16 units are kept fully laid out (no estimate regime) by a coalesced next-run-loop settle.
  • scheduleFullLayoutSettle / preservingViewportAnchor: the settle runs inside an anchor block so corrections never shift what is on screen.
  • repairContentAboveOrigin (+LazyStyling.swift, logs repairing content above origin): fixes the case where an edit near the top strands the first fragment at negative y (unreachable above the scroller top).
  • centerViewportOnCaret: re-measures after its first scroll and corrects the residual estimate error.

Rule: never trust an off-screen fragment's y-coordinate without laying out its span first. Deep write-up: docs/investigations/viewport-glitch-investigation.md.


3. The TextKit 1 fallback trap

An NSTextView can silently and permanently revert from TK2 to the legacy TK1 stack. Two known triggers: accessing NSTextView.layoutManager (the mere getter engages TK1), and storing NSTextBlock/NSTextTable attributes. Once reverted, TK2 APIs still exist but do nothing useful, and the whole editor misbehaves subtly.

Where it bites: Edmund ships a DEBUG tripwire — an observer on NSTextView.willSwitchToNSLayoutManagerNotification that asserts if the switch happens (EditorTextView.swift:273+, message "TextKit 1 fallback triggered"). Never add code that reads layoutManager or stores table attributes; draw tables as decorations instead (EditorTextView+TableRendering.swift).


4. Attribute-only mutation semantics

Edmund renders by writing attributes onto the storage, never by inserting or deleting characters (the storage == rawSource invariant). Two consequences from the text system:

  • setAttributes does not re-measure geometry. After a restyle that changes a block's height or indent, you must call invalidateLayout(for:) on its range or the fragment keeps a stale frame (empty bands / clipped lines). recomposeDirty and the idle drain already do this; new paths must too.
  • NSTextAttachment is only honored on the U+FFFC object-replacement character (background). rawSource never contains U+FFFC, so attachments can't be used — Edmund draws images/icons as overlays (§5) instead.

5. The custom drawing model (fragments, decorations, overlays)

DecoratedTextLayoutFragment draws two attribute families behind/over text:

  • .blockDecoration (paragraph-level): callout boxes, quote bars, table borders, thematic-break rules, code backgrounds. Fragments tile vertically, so a multi-line run renders as one continuous box. A box's bottomPad grows the last fragment's frame (TK2 omits trailing paragraph spacing from the fragment, so padding done otherwise would be dead space).
  • .fragmentOverlay (character-level): an image or a stroked vector path drawn at a glyph's laid-out position — rendered math, list bullets/checkboxes, callout header icon, the custom-title callout icon (path). The anchor glyph is hidden (≈0.01 pt font + clear color) and .kern reserves the drawing's advance width.

The image-on-wrapping-fragment wedge: drawing an image overlay on a multi-line (wrapping) fragment re-triggers a layout pass that collapses the fragment to one line. Drawing a shape/path does not. So the wrapping callout title's icon is a stroked CGPath (parsed by SVGPath from vendored Lucide geometry), never an image. Full saga: docs/investigations/archives/callout-title-wrap-investigation.md. This constraint holds for any new overlay that could share a line with wrapping text.

Hiding text = hiddenFont (≈0.01 pt) + clear foregroundColor. This is how delimiters (**, `, [!note]) vanish without changing the string.


6. The queued selection fixup (the round-6 delete-drift mechanism)

When you mutate an NSTextView's storage, AppKit queues a private step, -[NSTextLayoutManager _fixSelectionAfterChangeInCharacterRange:], that repairs the selection against the new character coordinates. Normally it fires promptly. But if an edit bypasses the normal close-out (see §7), the fixup stays queued and fires at the next endEditingeven an attribute-only restyle — where it maps the now-stale selection against post-edit coordinates and leaps the caret blocks away. It will move even a freshly set, valid caret.

Where it bites: this is delete-drift round 6. The heal must set the caret (from the pendingEdit hull) before the sync and re-assert it after (EditorTextView+EditFlow.swift). Recognize a variant by: a suspicious selection change arriving mid-recompose (up=Y in traces); traceSelectionOrigin will log the call stack of whoever moved it.

Critical for testing: a headless test harness runs this deferred fixup synchronously, so this bug class cannot reproduce in a unit test — the round-6 regression test passes with and without the fix. Only the live in-process repro discriminates (edmund-live-repro-and-diagnostics).


7. The AppKit edit pipeline contract (and where AppKit breaks it)

Normal edit: shouldChangeText(in:replacementString:) → the view calls replaceCharactersdidChangeText(). Edmund's didChangeText syncs rawSource from storage and restyles the edited block(s).

AppKit does NOT always send didChangeText. A drag-move of selected text whose drop lands on no valid target (e.g. released past the end of the document) deletes the dragged range via shouldChangeTextreplaceCharacters and never calls didChangeText — silently freezing rawSource/blocks, after which every edit drifts and autosave writes stale content (delete-drift round 4). Edmund heals this: shouldChangeText schedules a next-run-loop bypass check (scheduleBypassedEditSyncCheck, +EditFlow.swift); an unconsumed storage pendingEdit by then means the close-out never came, and the editor runs the sync itself (breadcrumb: healing storage edit that bypassed didChangeText).

Never build a sync path on the assumption that didChangeText follows every edit.

The authentic key route (background + repo): keyDown → interpretKeyEventsinsertText: / deleteBackward:. This is why the repro driver synthesizes real NSEvents and pushes them through window.sendEvent(_:) rather than calling insertText directly — shortcuts skip deleteBackward's selection machinery, which is exactly where round 6 lived.


8. IME / marked text lifecycle

While an input method is composing (e.g. CJK, accents), the view holds provisional "marked" text in storage; hasMarkedText() is true. During this window storage == rawSource is transiently false, and didChangeText defers syncing until the composition commits.

The cascade: any styling path that runs beginEditing/setAttributes/invalidateLayout mid-composition can strand the marked text in the input context. After that, didChangeText keeps bailing on its own guard and the invariant stays broken — so every later edit drifts the caret (the original delete-drift bug). Therefore every storage-touching styling path must guard !hasMarkedText() — including async ones scheduled before composition began (the caret-move restyle in +SelectionTracking). becomeFirstResponder resyncs from storage as a catch-all. Full write-up: docs/investigations/delete-drift-investigation.md.


9. Responder chain & nil-target actions (background + repo)

The responder chain is AppKit's search order for who handles an action. Menu items and toolbar buttons with a nil target send their action up the chain until something responds. Edmund's Format menu (FormatMenu.swift) is a declarative command table whose items use nil targets and route to the focused EditorTextView's @objc format… actions — the same wiring as undo/redo. The first responder is normally the focused EditorTextView.


10. NSWindow.sendEvent — the pre-toolbar event funnel

Every event a window receives passes through sendEvent(_:) before the toolbar acts. This matters because with NSToolbar.allowsUserCustomization = true, the toolbar claims any secondary (right/control) click over the toolbar — including a custom item view — for its own "Customize Toolbar…" menu, downstream of view-level handlers (menu, rightMouseDown, gesture recognizers all lose). Edmund's fix for the view-mode button: intercept in DocumentWindow.sendEvent(_:), pop the menu when the click is inside the button's bounds, and swallow it (return); other clicks fall through to super. (Caveat: true fullscreen moves the toolbar to a separate window, so this main-window hook wouldn't cover it.)


11. Drag sessions (background + repo)

  • Text drag-move arming: AppKit only starts a text drag after a mouse-down hold (~400 ms); a CGEvent driver must hold before moving or the drag never arms.
  • Drag-select autoscroll: dragging past the viewport edge autoscrolls.
  • Reveal at nearest end: a selection taller than the viewport must be revealed at its nearest end (Edmund's scrollRangeToVisible override) — always revealing the top fought the drag-select autoscroll and oscillated the viewport mid-drag.

12. swift-markdown walker model (brief)

Edmund parses with apple/swift-markdown (CommonMark/GFM) and walks the resulting Document with two back-ends: a SpanCollector-style walk that produces editor attributes, and HTMLRenderer that produces HTML for Read mode. One parser, two outputs — so Edit and Read can't drift. Custom (non-CommonMark) syntax — callouts, ==highlight==, wikilinks, comments, footnotes, math — is handled by SyntaxHighlighter+CustomParsers.swift.


When NOT to use this skill

  • The project's rules/invariants (what you must not do) → edmund-architecture-contract.
  • Which symptom means which mechanism → edmund-debugging-playbook.
  • Reproducing a live bug / reading traces → edmund-live-repro-and-diagnostics.
  • The history of how these mechanisms were discovered → edmund-failure-archaeology.
  • Running the caret-integrity campaign → edmund-caret-integrity-campaign.

Provenance and maintenance

Verified 2026-07-05. Re-verify the load-bearing identifiers:

grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
grep -n 'willSwitchToNSLayoutManager' Sources/EdmundCore/TextView/EditorTextView.swift
grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
grep -rn 'repairContentAboveOrigin\|preservingViewportAnchor' Sources/EdmundCore/TextView/
grep -rn 'blockDecoration\|fragmentOverlay\|hiddenFont' Sources/EdmundCore/
grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/

Items marked (background) are AppKit/TextKit behavioral facts documented by Apple and in docs/*-investigation.md, not directly observable by grep. If any Edmund mitigation name above no longer greps, it was renamed — update this file and docs/ARCHITECTURE.md §5/§8 together.

首页 - Wiki
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-07-13 19:46
浙ICP备14020137号-1 $访客地图$