Also bumps Flutter 3.47.3, flutter_secure_storage 11.1.0, idb_shim 2.9.9,
sqflite_common_ffi 2.4.3, very_good_analysis 11 and mcp 2.2.0 so the
freshness gate lets this through; v11 adds async_return_with_no_await, so
the 18 flagged return sites now await explicitly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF
429 -> 249 lines:
note_query.dart NoteSort, NoteFilter, and the pure predicates behind
them (matchesFilter, comparatorFor). All of it takes
notes and a filter and returns a decision, which is what
made it the natural seam.
note_import.dart ImportOutcome and the in-memory LogPersistence used by
openInMemory.
note_repository.dart re-exports both, so no call site changed.
Also drops ImportOutcome.total, which nothing called. It was uncovered
before this split too -- the larger file's percentage hid it. With it gone
the suite reports 100% line coverage (2005 lines), up from the 99.9%
baseline measured at the start of this refactor.
lib/ and bin/ are now fully under the cap. 313 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wires the shared gate (~/utils/scripts/check_file_length.sh) into a new
.pre-commit-config.yaml as a local hook, checking only the files in the
commit and failing rather than warning. No baseline, no allowlist.
The repo had a hand-written .git/hooks/pre-commit running two checks;
both are ported verbatim into the config rather than dropped, with the
version bump ordered last so it cannot rewrite pubspec.yaml for a commit
a gate is still about to reject. scripts/install_hooks.sh now installs
pre-commit (and removes the legacy hook) instead of writing its own.
.github/workflows/file-length.yml runs the same check on push, kept
separate from release-apk.yml so an over-long file fails lint rather than
blocking an APK release.
Splits the four cleanest violations, all behaviour-preserving:
note_template.dart 370 -> 203 + parser (186)
firebase_backend.dart 308 -> barrel (24) + project/store/client
port_guard.dart 315 -> 232 + linux_system_probe (100)
notes_list_screen.dart 698 -> 225 + 5 widget/nav files
Where a file had external callers, the original path stays as a barrel
that re-exports the split parts, so no call site changed.
Verified: 313 tests pass, coverage identical at 99.9% / 1922 lines
(per-file too -- notes_list's 281 covered lines are now 81+75+51+41+22+11),
flutter analyze clean, and a staged 260-line file makes git commit fail.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hide priority/status/template/mode chrome and routine sync-status text
behind a Firebase-synced "Enable advanced" setting (default off); remove
the Capture title and Save button in favor of autosave + a plain reset
icon; sort notes by priority then recency; add interaction-only usage
analytics buffered locally and flushed to Firebase alongside note sync.
Code review (self + follow-up verification pass) found and fixed 3 real
bugs during this work: a widget-key placement bug that discarded an
in-progress draft when advancedMode was toggled mid-typing, a data-loss
bug in the analytics buffer's flush-and-clear logic under concurrent
cap-eviction, and two spots where an expired/wrong-account Firebase
session (FirebaseAuthError) escaped a narrower catch clause and would
have failed an otherwise-successful sync pass.
Also fixes an unrelated pre-existing test-isolation bug in
wrapper_server_test.dart, which was reading the real
~/.config/todo/firebase_auth.json instead of a test-scoped path.
320 tests, 100% line coverage on all touched files (one pre-existing,
out-of-scope 5-line gap in settings_screen.dart unrelated to this
change).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A third of the freeform notes in the backlog are nothing but a pasted
URL, and the capture screen still offered them the seven-step spec
stepper. NoteTemplate.forDraft now picks `blank` for a draft that is
only links, so filing a link costs one paste.
The switch runs on every raw keystroke and is lossless in both
directions — it only ever fires while the raw body is the source, so
there are no section values to convert. Type prose alongside the link
and the spec template comes straight back. An explicit choice from the
dropdown or the guided wizard sets _templatePickedByUser and is never
overridden.
isBareLink is deliberately strict: one word of context ("read this
https://…") means the note has prose in it and keeps the spec template.
Verified on the phone (release APK, adb install -r): typing a bare URL
flips Template to Blank and drops the Guided segment; appending " port
this to Dart" restores LLM design spec and Guided. Test note created by
that check was deleted and the deletion synced (back to 49 notes).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audited the template against 514 real Claude sessions (1503 top-level
prompts) joined to the 49 notes in BACKLOG.md. Sections were judged on
whether the agent actually read them, not on fill rate — four of them
said "leave blank if none" in their own helper, so a low fill rate was
the section working correctly.
Measured over the 22 note/session pairs:
estimate filled 22, referenced 1 (4%)
done filled 22, quoted back 4 (18%)
refs filled 14, URLs fetched 5 (35%)
tech the agent opened pubspec.yaml/CLAUDE.md anyway in 54%
ask 100% ask rate with it, 92% without — no measurable effect
Kept: title, what, where, must, done. Dropped: tech (into `where`),
ask, nice and never (into `must` as `optional:` / `must not:` lines),
depends, estimate. Renamed `refs` -> `read first`. Added `verify`, the
only addition: "test it yourself, on the phone" was the single most
repeated correction in the corpus, and 3 of the 7 filled `never`
sections were already smuggling it in.
Fixes a latent data-loss bug found while testing this. parse() treats
an unknown `## heading` as content on purpose (values may contain
subheadings), so a legacy `## tech` block was folded into the preceding
section's value AND the note still reported conforming — opening any
old note in the stepper would have swallowed it on the next save.
NoteTemplate.retiredLabels now makes those notes explicitly
non-conforming, so they open raw and untouched.
tool/migrate_backlog.dart converts an old export (export -> migrate ->
import). It is idempotent and gated: it throws if a rewritten note does
not conform or if any source line vanishes. All 49 notes migrated on
the desktop and merged to the phone (0 new, 25 updated, 24 unchanged).
Also formats the few files dart format had never been run over, and
extracts a local in frame_stats_io.dart so the reflowed line stays
under 80 chars.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Backup recovery and file import both wrote restored notes through `upsert`,
which stamps a fresh clock. Under crdt_sync's per-field last-writer-wins that
makes a *restore* outrank every other device: the restoring device claims it
edited every field just now, so it silently overwrites newer edits made
elsewhere on the next sync.
This is not hypothetical. The new desktop app seeded itself from BACKLOG.md and
rewrote 245 of 330 field clocks to today under a fresh node id, then synced --
taking authority over the phone for ~74% of all fields. Content happened to
survive (the copy came from the same data; only a trailing newline on 5 notes
differed via the Markdown round-trip), but nothing about the mechanism
guaranteed that.
The legacy sqlite migration already did the right thing for exactly this
reason, so the fix is to use that same helper everywhere a note is *restored*
rather than newly written. Renamed legacyRecordFor -> recordAtNoteTime, since
it is no longer migration-specific.
The freshness check in importNotes is unchanged: it decides whether the import
wins *locally*. The clock decides whether it wins on *other* devices, and an
import must not win there merely by being recent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The desktop app is a browser now, so it cannot write files. This adds the other
half: a loopback HTTP server that serves the web build and owns the two files
the browser cannot touch -- `~/todo/BACKLOG.md`, which the user's tooling and
the todo MCP server read, and a disk copy of the note log so a wiped Chrome
profile is not a total-loss event for unsynced notes.
Notable details:
- Binds to 127.0.0.1 only. The endpoints overwrite files in the user's home
with no authentication, so a routable bind would let anything on the network
rewrite the backlog.
- Serves explicit content types. CanvasKit refuses a `.wasm` labelled anything
else and the app then renders nothing at all.
- Mirroring to disk is debounced. The store persists on every keystroke, so
mirroring eagerly meant an HTTP request plus a whole-log write per character;
IndexedDB still gets every keystroke, only the durability copy waits.
Separately, this fixes a pre-existing destructive bug found while testing:
`settings_screen_test` did real file I/O and overwrote the user's canonical
`~/todo/BACKLOG.md` with a single fake note on *every* `flutter test` run. It
went unnoticed because the app rewrites the file whenever it launches. The
export destination is now injectable and the test redirects it to a temp dir;
verified that a full suite run leaves the real file byte-identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First step of moving the desktop app off Flutter's Linux embedder, which tops
out at ~20fps at 4K (see docs/desktop-performance-findings.md). Android is
unaffected and keeps every existing code path.
The blocker was that `dart:io` was imported by note_repository, capture_screen
and settings_screen. On web it compiles to a stub and then throws at runtime --
the first web build rendered a blank white window. So each `dart:io` use now
sits behind a conditional export, with the shared logic hoisted into pure Dart:
- note_repository loses `dart:io`/sqlite entirely. Platform wiring moves to
repository_factory{,_io,_web}, and the legacy sqlite import moves to its own
io-only library. The HLC-from-updated_at seeding is preserved verbatim, so
independently-migrating devices still resolve pre-cutover edits by real edit
time.
- Notes persist to IndexedDB on web, not localStorage: the log is the primary
copy of the backlog and localStorage caps at ~5-10MB and is evicted eagerly.
- BACKLOG.md and the explicit Export keep working through a local wrapper
(added next). A browser cannot write `~/todo/BACKLOG.md`, and the user's
tooling plus the todo MCP server read that path, so leaving it to rot was not
an option. Writes are best-effort so the app still works with no wrapper.
- Notes are also mirrored to the wrapper's on-disk copy, so a wiped Chrome
profile is not a total-loss event for notes that have not reached GitHub yet.
`openRepository` is split so `openRepositoryIn` keeps the migration tests; the
suite stays at 174 green and 100% coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Standardizes on the same stricter lint set + shared analysis_options.yaml
used by diet-guard/app and the new habit_stack app. Fixes all 127 issues
surfaced: dart fix --apply for imports/cascades/sorting, unawaited() on
fire-and-forget Futures, bare catch(e) narrowed to on Exception catch(e)
(matching diet_guard's existing pattern), one documented ignore for a
deliberate TypeError catch around untrusted peer JSON, and missing
public-member doc comments throughout. No behavior change: 174/174 tests
pass, 100% line coverage maintained.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
NoteRepository is rewritten on crdt_sync's LogStore: each Note is stored *as*
a Record (per-field LWW + sticky tombstone), so the on-disk log IS the sync
wire format. Its public API is unchanged (upsert/delete/listNotes/watchNotes/
watchCount/importNotes/changes/nodeId), so the UI and most tests are
untouched; only the internals, the two sync methods (getChangeset/merge ->
exportLog/importLog over a Log), and the changeset-specific tests change.
Filtering/sorting now run in Dart over the in-memory log; watchNotes/watchCount
seed synchronously in onListen to avoid a subscription race.
SyncService moves from per-device sqlite changesets to per-device crdt_sync
Logs under a new todo-sync/notes/ path (clean format cutover; old clients
can't misread a new file). todo's own GitHubClient transport is kept.
One-time migration in NoteRepository.open: reads the legacy sqlite DB (kept
untouched as a safety net; the dep stays only for this), seeds every note --
tombstones included -- into the log with each field's HLC derived from its
updated_at (so a pre-cutover edit still wins by real edit time across two
independently-migrating devices), reuses the legacy node id, and sets a
persisted "migrated" flag so it never re-runs.
Verified: flutter analyze + dart format clean; 178 tests at 100% coverage
(incl. migration, tombstone, restore-race, and corrupt-peer cases). Device
verified on dev.kuhy.todo: real backlog of 27 live notes (+5 tombstones)
migrated intact -- confirmed against a pulled sqlite backup, the pulled
LogStore JSON, and the app's own "27 saved" -- notes render with status/
priority/dates, no crash, migration one-time.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ
The capture screen fed the on-disk BACKLOG.md backup off an always-live
watchNotes() subscription, so every keystroke re-queried the whole table,
re-parsed every row, and rebuilt the full Markdown export on the UI
isolate — O(notes) per character. It also fired a whole-screen setState
just to update the "Saved locally" timestamp.
- Add NoteRepository.changes: an O(1) broadcast tick after each write
(upsert/delete/merge; importNotes transitively). LocalBackup now pulls
notes via an injected fetcher only when its 2s debounce fires, so the
query + parse + Markdown export runs at most once per idle window
instead of on every keystroke. Capture listens to changes, not
watchNotes; a launch-time schedule() keeps BACKLOG.md fresh.
- Scope the save indicator to a ValueNotifier/ValueListenableBuilder so a
keystroke updates one line of text, not the AppBar/dropdowns/editor.
- Key list tiles by note id for identity-based reorder diffing.
Measured on a 4K/144Hz desktop the UI-thread build stays <=3.3ms;
coverage held at 100% on all changed files.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds `tech` (stack + versions) and `ask` (decisions needing human
approval) sections; renames `out` → `never` to match the three-tier
always/ask/never boundary system from Addy Osmani's good-spec article.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Notes were previously only openable via a quick-actions sheet; you
could not read or edit a note in full. Add a shared NoteEditor used by
both the capture and detail screens, plus selectable templates and a
rendered Markdown view.
- note_template.dart: pure assemble/parse layer over a Markdown subset
(# title, ## sections + italic guidance, dropping empty sections).
assemble(parse(text)) is idempotent for conforming text; non-conforming
/ legacy / freeform text is reported so the UI falls back to raw,
untouched. Two templates: llm-design-spec (default) and blank.
- note_editor.dart: View / Guided / Raw modes. Guided is an inline
Stepper (one step per section with its guidance); View renders the
note via MarkdownView; Raw is the verbatim text. Guided is offered
only for structured templates; switching to it is blocked when the
raw text no longer conforms.
- markdown_view.dart: lean read-only renderer for the note subset,
wrapped in a SelectionArea for copy-out.
- note_detail_screen.dart: full-screen note; opens in View, edits
persist immediately, priority/status dropdowns, delete.
- capture_screen / notes_list_screen wired to the new editor and detail
screen (tap a note opens it; quick actions move to the overflow button).
The editor is a view over plain text, so CRDT storage and Markdown
export/sync are unaffected. 138 tests, 100% line coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Notes list & filtering:
- Text-search filter plus independent date-range filters for both created
and last-updated (AND-combined), a priority filter, and a new status
filter. Default view hides Done/Abandoned and renders as "unfiltered"
(no badge for the default state); fixed badge clipping.
- NoteSort options wired into the list UI; watchCount() for the "N saved".
Status & priority:
- New Status enum (toDo/inProgress/Done/Abandoned) as a settable + filterable
attribute on every note, with capture-screen dropdown.
- Removed "None" priority: every note is Low/Medium/High, default Medium.
Schema migration v2->v3 rewrites legacy priority 0 -> Medium.
Export / import:
- NotesMarkdown round-trippable single-file format with HTML-comment markers.
- Settings "Export notes" (mobile share sheet / desktop writes ~/todo/BACKLOG.md)
and "Import notes" (file picker + safe newer-wins merge by id).
Structured template:
- Every new note pre-fills the richer what/where/must/nice/out/done/depends/
estimate/refs scaffold.
Tests:
- New fast (~5s), deterministic suite via FakeNoteRepository (no DB timers) and
injected http/file-selector/url-launcher fakes. 86 tests, 96.2% line coverage
(note.dart & sync_service.dart at 100%, settings 98.7%). Mobile-only share
branch excluded via coverage:ignore (unreachable on the Linux test host).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Flutter app for Android + Linux desktop. Captures ideas with per-keystroke local autosave to a CRDT-backed SQLite store (sqlite_crdt), and syncs through a private GitHub repo using per-device changeset files (conflict-free last-writer-wins merge). Includes GitHub OAuth device-flow sign-in with PAT fallback, a barebones notes list, and sync settings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>