19 Commits
Author SHA1 Message Date
Connor JohnstoneandClaude Opus 5 19049fc40c Add a README for the repo
Written for two readers: me in a year having forgotten all of it, and
anyone who stumbles on the repo and finds the idea worth stealing.

Leads with why the design gives things up rather than what it does, since
the giving up is the interesting part. Records the operational facts that
live nowhere in the code, points at FINDINGS.md for the things worth not
rediscovering, and ends with the changes this system should refuse.

Also marks validate.py and test_prune.py executable, since the README
invokes them directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
2026-09-09 21:04:05 -04:00
Connor JohnstoneandClaude Opus 5 2bf910757a Give the laptop client the base tracking the phone already has
Without a record of the last agreement, the client cannot tell "I have
unsaved edits" from "I am simply behind", and reported the second as the
first every time the phone pushed, leaving a .remote- file in the notes
directory each time. It now keeps the pristine text and its ETag under
XDG_STATE_HOME and merges three ways, matching the phone.

Two bugs found while testing that, both of which manufacture conflicts
rather than lose data, and both of which were invisible until a real edit
happened on each device at once.

diff3 was the wrong merge tool. It reports a conflict even when both sides
make the identical change, and ticking the same box on both devices is an
ordinary thing to do. git merge-file handles that cleanly and agrees with
the Android merge about what counts as a genuine conflict.

The merge result was not being recorded as the new base, so one sync later
the base was a version behind and an already merged edit looked like a
fresh change from both sides. Every point where the two are known to agree
now records that agreement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
2026-09-09 20:50:37 -04:00
Connor JohnstoneandClaude Opus 5 dd23ca67cd Add phase 5: local cache, ETag tracking, three way merge, conflict siblings
The editor now always reads and writes a local working copy, so the app
opens instantly and works with no signal. Beside it sits the pristine text
from the last agreement with the server and its ETag, which exists so a
later divergence can be merged rather than guessed at.

The merge is line based and three way, and is kept free of Android types
so it runs under JVM tests: a merge that is wrong loses writing rather
than merely looking wrong. 16 tests cover it, including the case the whole
thing exists for, a box ticked on the phone while a different box was
ticked on the laptop, and the harder ones, an insertion on one side that
must not desynchronise a later edit on the other, and repeated identical
lines that must not confuse the alignment.

Alignment is by longest common subsequence rather than by line number, so
an inserted or deleted line shifts what follows instead of mismatching
everything after it.

On a real conflict the server's copy stays as the note and the local one
is written beside it as a .sync-conflict- sibling, which is the convention
already in that directory. Nothing is adopted until that copy is known to
have been written, so local text is never replaced by something that
cannot be recovered.

Since SFTPGo ignores If-Match, the precondition is enforced by comparing
the stored ETag against the one a GET returns. The ETag a PUT reports is
not the file's own, so it is read back with a HEAD rather than believed.

Sync happens on foreground and on the button, never on a timer. The date
is recomputed on each sync, so an app left open across midnight moves to
the new day's note by itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
2026-09-09 20:42:32 -04:00
Connor JohnstoneandClaude Opus 5 0812b7b2e7 Toggle from the cursor offset, not from pointer coordinates
PLAN.md section 7 specifies getOffsetForPosition on a pointer position.
That cannot work: it expects layout coordinates while pointerInput reports
viewport coordinates, and BasicTextField scrolls internally without
exposing the offset. On a 1561 character note every tap resolved to an
offset of 253 or less, so it worked at the top and nowhere else.

The field is the thing that knows its own scroll, so it now places the
cursor and that offset is read back from onValueChange. The press is
watched only to distinguish a tap from any other cursor move, and is never
consumed, so scrolling and ordinary cursor placement are untouched. That
also retires the earlier trade off where a scroll starting on a box would
not scroll.

After toggling, the cursor is parked past the box. Left inside, a second
tap resolves to the offset it already holds, and an unchanged selection
reports nothing, so a box could not be unticked by tapping it twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
2026-09-09 20:36:04 -04:00
Connor JohnstoneandClaude Opus 5 bb66567725 Add phase 4: markdown highlighting, tap to toggle, JetBrains Mono
Highlighting styles markers in place rather than hiding them, so the
offset mapping is the identity and cursor maths needs no translation.
The span finding is deliberately free of Compose types, which puts the
offset arithmetic under 25 JVM unit tests instead of under a phone.
Several cases come straight from the corpus: `*arr` must not italicise,
`- [ ]` must not parse as a link, snake_case must be left alone.

Tap to toggle did nothing on the first attempt, and the reason was not
where it looked. A diagnostic showed the handler firing and resolving
exactly the right offsets, so detection was fine. The release was the
problem: BasicTextField consumes the tap, and waitForUpOrCancellation
returns null for a consumed pointer, so the press arrived and the release
never did. Leaving the press unconsumed, to protect ordinary cursor
placement, was what handed the gesture away. Now the press is claimed in
the Initial pass, but only once the offset is confirmed to be inside a
bracket span, so every other tap is untouched.

The same diagnostic disproved a plausible worry, that a scrolled field
would mis-resolve taps because getOffsetForPosition works in layout
coordinates and pointerInput reports viewport ones. The offsets were
correct, so a correct piece of code was left alone.

Bundled the no-ligature cut of JetBrains Mono. A ligature draws two
characters as one glyph, which is at odds with an editor built on every
character staying visible. Static faces rather than the variable file,
which needs an experimental API, and a real bold rather than a
synthesised one. OFL text is in licenses/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
2026-09-09 20:21:56 -04:00
Connor JohnstoneandClaude Opus 5 1e91ff8f07 Let BasicTextField own its scrolling, and stop double counting the IME
Typing past the bottom of the screen left the cursor off screen. The field
was wrapped in verticalScroll, which measures its content with unbounded
height, so it laid out at the full height of the text and never scrolled
itself. Its bring-into-view logic had nothing to act on, and the outer
scroll container had no idea where the cursor was. Bounded height is all it
needs to handle both.

Confirmed structurally as well as by feel: wrapped, the accessibility tree
reports a bare View inside a ScrollView; unwrapped, it reports an EditText.

Also removed imePadding from the screen. The activity's safeDrawingPadding
already covers the IME, so the keyboard inset was applied twice.

PLAN.md section 7 is right that this behaviour is the whole experience, and
right that it only shows up on real hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
2026-09-09 20:03:44 -04:00
Connor JohnstoneandClaude Opus 5 65245def96 Record what HyperOS permits over adb
adb install is refused by Xiaomi's own gate rather than by any AOSP
restriction, and input injection is refused as well. push, shell, dumpsys
and logcat all work, so the debugging that phases 4 and 5 depend on is
intact and only the install step needs a hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
2026-09-09 19:47:29 -04:00
Connor JohnstoneandClaude Opus 5 3c3bfc256d Add a deploy helper and record what changed in the Android tooling
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
2026-09-09 19:35:07 -04:00
Connor JohnstoneandClaude Opus 5 3a20b89b07 Add the phase 3 app: fetch, edit, save, launcher icon
One screen, a monospace BasicTextField, OkHttp against the WebDAV path.
No syntax highlighting and no tap to toggle; those are phase 4.

Three things the plan's design notes turn into concrete constraints:

OkHttp's `authenticator` is deliberately not used. It implements auth by
retrying after a 401, which is precisely the pattern section 5 says must
never happen, so credentials go on every request pre-emptively instead.
Sync is wired to repeatOnLifecycle(RESUMED) and to the save button, never
to a timer, because a background poll reads as http-crawl-non_statics.

Typing is written to app-private internal storage as it happens. Section 8
is right that in-memory state is not durable, and this is the smallest
thing that stops a process death discarding an edit. It is not the phase 5
offline cache: there is no ETag history and no merge yet.

PLAN.md section 7 asks for EncryptedSharedPreferences, and it works, but
androidx.security-crypto 1.1.0 deprecates the whole API. Kept for now,
with the failure mode that deprecation is about defused: the generated
manifest had allowBackup="true" and referenced no rules at all, so the
encrypted prefs would have been backed up without the Keystore key that
decrypts them, and a restore onto a new device would have thrown on open
and crashed the app on launch until its data was cleared. Credentials and
drafts are now excluded from both cloud-backup and device-transfer, and
backup_rules.xml is deleted since fullBackupContent is ignored above API
30 and minSdk here is 36.

Verified in the built artifact rather than the source: minSdk and
targetSdk 36, INTERNET the only real permission, MAIN/LAUNCHER present,
signed with the debug key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
2026-09-09 19:25:47 -04:00
Connor JohnstoneandClaude Opus 5 7a56008f2e Scaffold the Android app, unmodified generator output
Committed on its own so the next commit shows only hand-written code.

PLAN.md section 8 assumes cmdline-tools plus sdkmanager. That has moved:
cmdline-tools 23.0 deprecates sdkmanager for a new `android` CLI which
also scaffolds projects, installs packages without a licence prompt, and
can build and deploy to a device. The project here is `android create
empty-activity --minSdk 36`.

Android 16 is API 36, so minSdk and targetSdk are 36, matching the phone
that has to run it. compileSdk stays at 36 rather than the newest 37 that
section 8 suggests, because nothing here names an API 37 symbol and the
template's AGP 9.0.1 pairing is tested at 36. Raising it later is a
one-line change if a reason appears.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
2026-09-09 19:22:11 -04:00
Connor JohnstoneandClaude Opus 5 1eef5e5ae3 Add the WebDAV-syncing laptop client
Fixes the three defects PLAN.md section 6 lists. The previous-note glob is
constrained to the date pattern, so a .sync-conflict- sibling can no longer
be selected as the file to carry forward, which is the likeliest origin of
how those conflicts spread. The retitle is line 1 only, so a body line like
"meeting moved to 2026-09-08" is no longer silently advanced a day. The dead
`&& cd -` is gone.

Pull before the edit, push after. Error handling is shaped by the CrowdSec
rules in section 5 rather than by generic retry logic: a 401 stops dead with
a message, a 404 means the timer has not run yet so it creates once and
never polls, and anything else degrades to editing the local copy.

The offline fallback shells out to the same prune.py the server rollover
uses, installed to ~/.local/lib/todo, so the two cannot drift.

Since SFTPGo ignores If-Match, the precondition is enforced client side by
comparing the ETag captured at pull against a HEAD before the PUT. A remote
that moved mid-edit produces a .sync-conflict- sibling and a non-zero exit
rather than an overwrite. Real merging is phase 5; this just makes phase 2
incapable of losing an edit.

The credential comes from rbw and reaches curl over stdin, never argv, where
any other user's ps could read it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
2026-09-09 18:59:16 -04:00
Connor JohnstoneandClaude Opus 5 45f0fca444 Untrack a .pyc that predated the __pycache__ ignore rule
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
2026-09-09 18:35:30 -04:00
Connor JohnstoneandClaude Opus 5 caa367feb2 Fix review.sh inventing 12 removals that the prune never makes
It round-tripped the pruned text through a shell variable, and command
substitution strips every trailing newline while printf added exactly one
back, so any file whose pruned output ended in more than one blank line
diffed against a reshaped copy of itself. Comparing files directly instead
brings review.sh to 270 removals across 118 files, matching validate.py,
which derives the same number from the drop set rather than from a diff.

The counting was wrong too: a removed `- [x] foo` is `-- [x] foo` in a
unified diff, so the `^-[^-]` pattern skipped every checkbox it was meant
to be counting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
2026-09-09 18:35:25 -04:00
Connor JohnstoneandClaude Opus 5 1053310d32 Add review.sh to regenerate the whole-corpus prune diff on demand
The plan gates the timer on reviewing this diff, and the same gate should
be re-runnable after any change to the rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
2026-09-09 18:34:20 -04:00
Connor JohnstoneandClaude Opus 5 213e285c37 Add the server rollover, its systemd units, and an installer
Never modifies an existing note. It only creates today's file when that
file is absent, so the blast radius is one new file and re-running is a
no-op.

Two bugs that only a real run on the server could surface. tempfile
creates 0600 and os.replace preserves it, so notes would have landed
private in a tree where everything is 0644 and both sftpgo and syncthing
read; the previous note's mode is now carried over rather than hardcoded.
And --dry-run fell into a degenerate branch once today's file existed,
emitting a bare heading exactly when a preview is most wanted, so it now
always previews from the newest note that is not today.

The unit is sandboxed, and that was verified against the NFS mount rather
than assumed: writes to the notes directory succeed, and writes to its
parent are refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
2026-09-09 18:33:54 -04:00
Connor JohnstoneandClaude Opus 5 185b45dbfc Add the prune rule, its tests, and a corpus validator
The rule PLAN.md describes and the rationale it gives for that rule
disagree on one shape that turns out to be common. 235 checkboxes in the
corpus sit under a plain bullet used as a group label, and by the letter
of the rule those have no checkbox ancestor, so a finished one would be
dropped on its own. But section 4 already argues the opposite case for
checkbox parents: a finished item under an unfinished group is context,
not cruft, and a label like `- Trellis "Ergonomics" Fixes` is a group in
every sense that matters.

Resolved in favour of the rationale, which collapses both cases into one
rule. A top-level bullet is any bullet with no bullet ancestor in its
section. It is dropped, with its subtree, exactly when that subtree holds
at least one checkbox and all of them are [x]. Prose bullets hold none and
so are never eligible, which is the property section 4 wanted.

Validated across all 228 notes with zero invariant failures: no heading,
no unfinished item and no checkbox-free subtree is ever removed, and no
group label is left with its children gone. 270 lines would be dropped
across 118 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
2026-09-09 18:30:29 -04:00
Connor JohnstoneandClaude Opus 5 727813b5e9 Record that the NAS crystal is sound, the clock was just never set
chrony measures the hardware at 2.6 ppm, so the 124s was about eighteen
months of accumulation on a machine with no time daemon, not drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
2026-09-09 18:20:53 -04:00
Connor JohnstoneandClaude Opus 5 6033f9c38d Reconcile notes, verify WebDAV, fix NAS clock drift
Phase 0. Resolve the four Syncthing conflict files and push the laptop's
228 notes to mainframe, both sides now hashing identically.

Settle the If-Match question PLAN.md deferred to phase 1: SFTPGo's WebDAV
returns ETags but ignores the precondition, so a PUT carrying a bogus
If-Match still writes. The client takes the HEAD-compare-then-PUT fallback.

Chasing a PUT/HEAD ETag mismatch turned up a NAS whose clock was 124
seconds fast with no time daemon installed, which NFS was stamping onto
every file mtime under /docs. Installed and enabled chrony there. The
mismatch survives the fix at ~1ms, so it is a write-path race in SFTPGo
that the broken clock had been amplifying, not a protocol defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
2026-09-09 18:20:11 -04:00
Connor JohnstoneandClaude Opus 5 275ecd78ac Add plan of record for cross-device todo notes
Extends ~/.local/bin/todo to Android without turning it into an app. Notes stay
plain markdown in ~/docs/notes/todo; this repo holds the tooling.

Key finding: files.rcjohnstone.com already serves /home/connor/docs over
authenticated WebDAV, and sftpgo and syncthing mount the same host directory, so
today's note is already reachable at a public TLS URL. No new service, hostname,
Caddy block, or credential store is needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NctNN59e573MxYdNWzNHUw
2026-09-09 17:53:32 -04:00