Skip to content

feat: reading depth — canonical oracle texts, daily anchor, journal instrument, bound entropy#9

Merged
pro-vi merged 346 commits into
mainfrom
feat/reading-depth
Jun 21, 2026
Merged

feat: reading depth — canonical oracle texts, daily anchor, journal instrument, bound entropy#9
pro-vi merged 346 commits into
mainfrom
feat/reading-depth

Conversation

@pro-vi

@pro-vi pro-vi commented Jun 11, 2026

Copy link
Copy Markdown
Owner

Summary

A cast now delivers a reading, not just a picture of one. This branch adds the canonical oracle texts (卦辭, Legge judgments, 小象傳, 序卦/雜卦) to the data layer and surfaces them everywhere a reading lives — the post-cast scene, the dictionary, the journal, and the CLI/LLM output — governed by the classical reading-method rules. Around that core: a daily ritual anchor ([t] / iching today), the journal grown into an instrument of reflection (notes, search, patterns), the dictionary turned navigable web, the SPEC's named next scope (entropy binding) implemented with honest provenance, and a deep hardening pass over input, terminal lifecycle, and storage.

223 commits · 194 files · +21,416 −1,042 · tests 595 → 1341

Key Changes

The Reading (core + cast scene + dictionary)

  • Canonical texts: all 64 hexagrams gain gc (卦辭), gcEn (Legge, public domain), yaoXiao (小象傳 ×6), 用九/用六 on hexagrams 1–2, and 序卦/雜卦 sequence texts — injected mechanically from the repo's data-acquisition sources via committed codemods, pinned by exact-string tests.
  • Post-cast reading panel: once the reveal settles, the changing lines' 爻辭 render in the cast scene (bottom-first), with a one-line classical hint naming which text governs: 1 line → that line; 2–3 → the upper governing line leads; 4–5 → the becoming hexagram's judgment; all six → becoming (or 用九/用六 on 乾/坤); none → the judgment is the reading. Panel and glyph share the vertical budget — the texts win, the glyph shrinks → compacts → yields (composed-scene tests at 80×24/100×30, en + zh-Hant).
  • Dictionary depth: judgment renders first, 小象 under each line, cast context marks your moving lines when arriving from a reading, 序卦/雜卦 close the page. ←/→ walks the King Wen sequence (router replace, esc stays sane); trigram-aware search (water over mountain, 山風, ☲☱); iching hexagram 泰|tai|"the receptive"; iching dict <query> prefill.

Daily Anchor

  • Home gains [t] to reopen today's reading (stale-safe across midnight); esc on Home is a no-op instead of killing the session.
  • New iching today / today --json: the full reading from the daily cache — judgment, changing-line texts, intention, method, rng provenance — as the one-call LLM/assistant integration surface, with stable JSON keys in both states.

Journal as Instrument

  • Reflection notes: annotate past readings ([n] in the TUI, iching journal note), persisted to a notes.jsonl sidecar so journals stay readable by the shipped 0.4.0 binary; honest pending → saved | failed persistence with in-flight writes drained on exit.
  • Incremental [/] search (intention text, names, pinyin, number), [g] jump to the entry's hexagram with moving lines marked, j/k + home/end parity.
  • 觀象 patterns pane ([p]): a composed contemplative observatory, not a stat dump, refined over many passes into a single ink-on-paper instrument. An 8×8 field of all 64 hexagram glyphs opens it — brightness-tiered by how often each has been cast (○ not yet · ◦ once · ◐ a few · ● often), with the single accent reserved for the most recent reading (◉ now): two channels, frequency and recency, with no added marker. The day's facts annotate the field beside it (reflowing beneath when a terminal is too narrow to hold both). Then ruled sections, each opening with a hinge and a flush-right margin-whisper note: 卦象 faces seen · 爻象 movement read top-down · 八卦 trigrams · 次第 succession with an eighth-block drift sparkline · 卦變 turnings & echoes · and a closing 兩儀 (two modes) coda — the yang/yin balance of every line drawn, a split bar growing out from a still central axis. Bars are soft ink on a faint groove (one glyph family with the sparkline), not HUD blocks. Statistical jargon (lift/residual/entropy/Hamming) never reaches the screen; the register is observed counts beside "by chance ~N" (理數約), gated behind enough method-marked readings and honestly withheld below that, with the observed and expected sides of every comparison held to a single basis. Aligned label column (display-width padding), graceful clipping, measured field reflow, and a no-color fallback (dim/plain/bold) keep it legible from 80 cols down to narrow splits, in en / 繁 / 简. The derivation is memoized so the 30 FPS loop never recomputes it, and one malformed reading can never blank the pane.
  • iching journal patterns [--json]: the same observation, now agent-readable. The pure derivation (computeJournalPatterns) was relocated to @iching/core and exposed on the CLI — a calm one-screen plain digest, and a --json surface that resolves hexagram numbers to name blocks and namespaces every method-marked comparison under its own comparison block (with a top-level basis note) so a consuming script never crosses descriptive counts with method-only expectations.

Entropy Binding (graduates the SPEC's Active Product Question)

  • BoundRandomSource: seed = SHA-256 over length-prefixed (32 fresh CSPRNG bytes ‖ intention ‖ ISO moment ‖ process nonce), hash-counter DRBG expansion. Chance stays primary — the same intention can never deterministically reproduce or steer a cast. Opt-in (config entropy bound / cast --bound); --seed untouched.
  • Provenance recorded as rng:{source,intentionBound} through journal, cache, and JSON; one quiet plain line for bound/seed casts; crypto default stays silent. Settings row with ratified zh chips (機器 / 繫於心念) and an animated confluence preview.
  • Externally reviewed (GPT-Pro extended, hypothesis-framed): construction judged practically sound; the four actionable findings (entropy-override floor, NFC intention normalization, counter-exhaustion guard, precise doc claims) are applied with regression tests.

Terminal & Input Foundation

  • Full CSI/SS3 escape parsing (Delete/F-keys/modified arrows no longer eject the user from intention entry), bracketed paste (capped, timeout-flushed), split-UTF-8 buffering, delete/paste KeyEvents wired through all text inputs.
  • Persistent alt-screen across scene transitions (no more shell flash), full repaint on resize, DEC 2026 synchronized output, crash-safe terminal restore (unhandled-rejection handlers + ownership-aware scene loop), too-small-terminal notice that actually gates input, midnight rollover fixed, emoji/CJK width parity between Bun and Node.

Storage Robustness

  • Torn-line tolerant JSONL with self-healing append (a torn tail can no longer eat the next reading), deep shape validation on journal entries and the daily cache with quarantine sidecars, skippedLines surfaced in journal stderr notes and a new doctor integrity check, cast-method provenance, config positional shorthand, --seed/--limit/--since validation, control-character sanitization for note text at write and render.

Visual & Docs

  • Shared truecolor lerp, per-style glyph reveal timing, 256-color quantization fix, themed taijitu, toss ground feedback, theme-token integrity.
  • READMEs (en/zh-CN/publish) synced with the new surface; SPEC updated (entropy graduated, today command); CLI self-reports the right version and name; bun run smoke works bare; language-surface gate extended to the new files with coverage locks.

Post-review hardening (later passes on this branch)

Several review rounds after the feature landed — an adversarial integration review, a code review, an external branch audit triaged end-to-end, and /gate + /refactor passes — produced a layer of correctness and consistency fixes:

  • One canonical reading everywhere: extracted readingTexts as the single source the TUI panel, CLI plain, and JSON all render, so they can no longer drift — the CLI paths had been equating the reading with the raw moving lines (wrong at 3/4/5 moving). The reading now speaks one English register (Wilhelm gcEnW, the displayed default) with the verbatim-Legge gcEn kept as the scholarly anchor; journal show surfaces the same reading as the fresh cast, static 卦辭 included.

  • Read-time cast integrity: isCastShaped now reconstructs each persisted cast from its lines and compares — a hand-edited/imported record can no longer pass validation while showing a false reading (one hexagram's lines labeled as another's). Benchmarked acceptable (full-stream re-derivation ~96ms at 50k entries; the per-prompt hook validates only the tail).

  • Hook idempotency: on a lost daily cache, the hook recovers today's reading from the journal instead of recasting — no more unbounded duplicate journal rows on every shell prompt.

  • Journal correctness: patterns --until <past> reads cadence as of the window's end, not real today; entryTimeKey trusts only a parseable timestamp (an empty/garbage string no longer sorts as the epoch "oldest"); reflection notes pin to the exact reading via a content ref (a note on one of several same-day legacy casts stays put).

  • Yarrow lifecycle: below the field floor the ritual freezes and ignores ritual keys (no advancing or "receiving" a reading the user can't see), via a shared field-floor helper.

  • doctor: --json now exits non-zero on a failed check; the cache check flags a parseable-but-shapeless record as a reset, not "valid"; atomic writes derive the temp name with basename().

  • Test & tooling: a shared @iching/core/testing cast-fixture builder (ends the fixture lockstep the integrity check introduced), end-to-end paths/hexagram command tests, a re-runnable journal-read benchmark, and the journal-patterns type contract extracted into its own module.

  • Input & terminal polish: Option/Alt+Backspace now deletes a word in every text input instead of ejecting the scene (ESC + byte is the Meta-prefix convention, never a bare Escape; unbound Alt-combos no-op); autowrap is disabled on session enter (CSI ?7l) so a row measuring one cell too wide — e.g. an ambiguous-width glyph a CJK font renders double-wide — clips instead of spilling its trailing (background) cell onto the next line; and the dictionary list drops its redundant trigram-pair column (the hexagram figure already carries the structure; its ambiguous-width glyphs were the bleed's source).

  • Durable-recovery invariant pass: codified one principle — the journal is the source of truth; the daily cache and note refs are lossy mirrors — and made every read path obey it. (1) One recency comparator (compareEntryTime) across the TUI journal list, the CLI (journal list / show <date> / note --date), and the patterns pane, so they agree on which of several same-day undated readings is "most recent" (a time-key-only sort broke the tie by append order, the pane by content). (2) A single resolveTodayReading resolver (cache-first, journal-fallback) behind both iching today and the home [t] reopen, matching the hook — a missing/stale/quarantined cache no longer hides today's reading, which still lives in the journal. (3) CLI note --date writes the same precise content ref the TUI uses (a bare date re-attached the note to the day's last cast). (4) The hook reads entropy config quietly, so a corrupt config can't spam stderr on every shell prompt. Anchored as a checked invariant with a reviewer checklist in docs/process/durable-recovery.md.

  • Patterns module decomposition: the 652-line patterns.ts split by concern into patterns/chance.ts (observed-vs-expected model), patterns/time.ts (temporal keys & day-phase), and patterns/summaries.ts (accumulate-then-emit per summary) behind a byte-identical public surface — function bodies moved verbatim, guarded by the existing 604-line derivation suite.

  • /casting + /naming + a multi-lens review workflow: /casting came back clean (zero as any/suppressions; every JSON.parse is parsed-then-validated at the storage boundary); /naming renamed one I/O verb (getHexagramHistoryloadHexagramHistory, matching its load* sibling). Then an 84-agent review fan-out (/code-review + /failure-mode + /invariance + /perf over the diff, looped to convergence with an adversarial majority-skeptic re-verify of every survivor) surfaced ~16 unique findings; every real one fixed and mutation-verified:

    • perf (MAJOR): the dictionary's King Wen walk re-read the whole history.jsonl on every ←/→ keystroke — now one shared scan across the walk; the reading panel, 觀象 rows, and journal search are memoized so they don't recompute every frame / keystroke; bracketed-paste accumulation is O(N), not O(N²).
    • validation & sort: cache structure validation is deep (a half-shaped record no longer renders Upper: undefined); entryTimeKey canonicalizes to UTC so a zone-offset stamp sorts chronologically; the notes-path invariant is pinned by a test.
    • durable recovery: resolveTodayReading re-warms the cache on journal recovery; the legacy bare-date-note and seeded-cast edge cases are reconciled in docs/process/durable-recovery.md.
    • timezone is now real: config.timezone was settable but never consumedlocalToday() and the 時 phase-of-day binning now honor the configured zone (machine-local for "system") via a pure core/zone.ts.
    • Genuinely-bounded items (per-line integrity cost ~96 ms@50k, journal list full-materialize, store.latest() append-order) were left as documented accepted-risk, not silently "fixed".
  • Independent review + cross-model audit (latest pass): a final independent review — 21 risk/correctness personas fanned across the subsystems (correctness, adversarial, composition, invariance, reliability, security, api-contract, user-flow, migrations), each finding adversarially re-verified, plus a cross-model codex review (gpt-5.5, xhigh) over the whole branch — surfaced 10 findings (0 P0/P1). All triaged end-to-end and mutation-verified (revert → new test fails → restore):

    • exact hexagram lookup (P2) — a name that is also a trigram token (乾/坤/離 …) pulled in the whole trigram family as weak matches, so the CLI resolver saw length > 1 and printed a shortlist instead of opening the hexagram (乾: 1 hit → 15). Fixed: the resolver treats a unique score-0 hit as the answer, and searchHexagramsScored keeps the family for the dictionary browse filter. Caught only by the cross-model pass — the in-tree personas judged the family scoring correct within search.ts; the regression lived across the module boundary in the resolver's length check.
    • untrusted date (P2) — the journal/note date rendered raw while intention/note.text were already stripped, so an imported/hand-edited record could replay ESC/OSC control bytes on journal list|show and today. Fixed at the parser, covering every render surface.
    • cast-scene reveal (P2 + 2×P3)[s] skip restarted the glyph animation instead of settling it (now skipToComplete(false)); the virtual clock credited the entire hidden gap after a too-small resize, amplified by pace (now advances by the clamped dt, like yarrow); the centered becoming-title overlapped the reading panel on tall-narrow terminals (suppressed while the reading shows).
    • bracketed paste (P2) — the dangling-paste flush keyed on the inter-chunk gap, so a >500 ms stall mid-paste split the paste and a trailing newline could submit the form. Fixed: flush on absolute paste age, armed once, never re-armed or cleared by feed.
    • journal P3s--all no longer rejects an (ignored) malformed --limit; the search input gained caret / forward-delete / home / end parity with the note input; journal re-entry re-clamps the cursor like resize, so the selected reading can't sit off-screen after a resize-while-pushed.
    • ESC+byte (P3) — wontfix by design: a coalesced ESC<byte> is the Meta/Alt-prefix convention (a real Escape arrives as a lone ESC), as documented and tested.
    • earlier Codex pass, folded in: threaded the configured clock into the post-cast journal path, validated the cached becoming structure, and quieted today's config read and the per-prompt daily-cache reads so a corrupt config/cache can't spam stderr.
    • One review correction recorded: the reported breadth of the lookup regression was narrowed — tone-stripped pinyin (qian→乾/謙, kun→坤/困) and the English image words were already ambiguous on main, so only the exact Chinese names regressed; the fix keeps the genuinely-ambiguous pinyin a shortlist, pinned by test.

Technical Context

Built by parallel implementation agents in isolated worktrees (two waves + recovery), merged with hand-resolved conflicts, then hardened through several review passes: a 29-agent adversarial integration review (23 confirmed findings — including the reading panel being pushed off-screen by the glyph at every realistic terminal size, and the hook adapter erasing rng provenance — all fixed), an external second opinion on the entropy construction, a 12-finding multi-reviewer code review (all fixed), and finally an 84-agent multi-lens review workflow over the whole diff whose ~16 unique findings were fixed-or-documented (each fix mutation-verified). A final independent pass — 21 risk/correctness personas plus a cross-model codex review (gpt-5.5) over the whole branch — surfaced 10 more findings; the sharpest, an exact hexagram-name lookup regressing to a shortlist, was caught only by the cross-model hedge, since the in-tree personas judged the trigram-family scoring correct within search.ts while the defect lived across the boundary in the CLI resolver — nine fixed, one wontfix-by-design, each mutation-verified. Notable design decisions: reflection notes live in a sidecar file specifically to avoid a one-way door for 0.4.0 readers; the reading panel owns the layout budget because the oracle texts are the product and the glyph is ornament; provenance language is bound by the vision doc's register — participation, never efficacy.

Testing Scenarios

  • Full suite: 1341 pass / 0 fail (bun run test), typecheck clean, language-surface gate PASS — 0 issues
  • Binary build + smoke: 5/5 (bun run build && bun run smoke)
  • Cast at 80×24 and 100×30, en/zh-Hant/zh-Hans, 0/1/2/3/4/5/6 moving lines — panel text matches the governing rule, footer intact
  • Journal: note → quit-immediately durability; torn line + malformed record + note interleaved — list/show/--json survive with stderr note
  • iching today --json stable key set in both states; hook run preserves rng provenance
  • Old-format journals (pre-method/rng/notes) read clean; corrupt cache quarantines instead of crashing startup
  • --seed abc exits 1 in cast, TUI, and hook modes; --seed 42 deterministic

Related

  • Graduates Entropy Binding from SPEC.md Active Product Questions (implemented 2026-06-10)
  • Yarrow Line-Gate variant and embodied-entropy v0 remain deferred per SPEC/vision docs

Generated with: Claude Code

🤖 Generated with Claude Code

pro-vi and others added 30 commits June 13, 2026 06:06
…n leave

The zero-readings branch wrote 'No readings yet' and returned before
renderFooter, so the empty journal had no footer at all. Every other render
path (list, no-results search) shows one — the most novice state (a brand-new
user, no readings) was the only one with no visible way out.

renderFooter gains an empty-journal case: the populated footer advertises
view/note/search/patterns, all dead with zero entries, so the empty state
shows only the one real action — the way back. Verified in en and zh-Hant.
Mutation-checked: dropping the footer call fails the new regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ible

TextInput.render always painted from the head of the text, so once a value
grew past the field width the view froze on the opening characters with the
cursor — and every new keystroke — off screen. Typing a reflection note past
~60 cols (or any note on a 40-col terminal) meant you could no longer see
what you were writing.

Render a window that tracks the cursor instead: scroll so the tail up to the
cursor shows, the expected single-line-input behaviour. Measured in display
columns via stringWidth and started on a character boundary, so CJK never
clips mid-glyph. Text that fits is unchanged (cursorCol < width → no scroll),
so every existing render test still holds. Fixes both the journal note input
and the dictionary/journal search field. Mutation-checked: head-start fails
the new regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ions

The horizontal-scroll fix shipped with a regression test for the type-at-the-
end case only. Add the full window-tracking invariant: across Home, mid-text,
and End on an overflowing field, the cursor block is always on screen, and
Home returns the view to the head (the scroll does not stay stuck at the
tail). Guards mid-text editing, not just appending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hrinks

resize() updated the list's viewport height but never re-ran the cursor-into-
view math — only the patterns scroll re-clamped itself. So shrinking the
window with the selection near the bottom dropped the highlighted reading
below the new fold; it stayed off screen until the user arrowed it back.

Re-run ensureCursorVisible() on resize, the same call the move keys use, and
guard the list viewport with Math.max(1, …) to match the patterns scroll.
Verified the selection survives shrink, the row floor, and growing back.
Mutation-checked: dropping the call leaves the cursor off screen (row -1) and
fails the new regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cator

The list scroll indicator ("3/26 (40%)", right-anchored) and the selected
reading's image/note preview both render on row rows-2. The layout means them
to coexist — preview left, indicator right — but the preview truncated to the
full width (maxW-4) without reserving the indicator's space. A long image
(most 大象傳 lines) overran it, overwriting all but the indicator's last
column and leaving a stray ')' glued to the ellipsis: "…reduces exce…)".

Only surfaced once the list overflowed its viewport (the indicator's trigger)
with a long-image reading selected — e.g. after a shrink, which is how it
turned up. Extract scrollIndicatorText() so renderList and renderPreviewRow
agree, and reserve its width from the preview's budget. Mutation-checked:
without the reservation the overrun and stray fragment return.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… set

Adversarial state review (the logic my rendering audits can't see): with the
cursor near the bottom of the full list, a query that filters to fewer
readings must clamp the cursor into the new range, or it indexes past
`filtered` — a crash on filtered[cursor] or no selection rendered. setQuery
clamps it, but no test covered the bottom-cursor → shrink case.

Lock it: cursor 17 over 18 readings, filter to 3, assert the cursor lands in
[0,3) and a selection still renders. Mutation-checked: dropping the clamp
leaves the cursor at 17 and fails. Verified the rest of H2 (empty result set,
clear-and-restore) and the async note-persistence state machine hold by
inspection; an external GPT-Pro logic review of the scene is queued.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Continuing the adversarial state review (H4). Key routing checks
noteActive → patternsOpen → searchActive and returns, so a key that would
open another mode is captured by the active handler as input — [p] while
searching types 'p', it does not open the patterns pane. Verified empirically
across every cross-mode sequence: at most one mode is ever live and esc always
returns to the clean list.

Only individual transitions were tested before; this locks the holistic
invariant. Mutation-checked: dropping the return on the search route lets [p]
fall through and open patterns alongside search (two modes live) — the test
catches it (2 active, expected 1).

H1–H4 of the queued GPT-Pro logic review now all hold by direct inspection;
that review is still running (partial context) and will be read next pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An external GPT-Pro logic review (its highest-priority finding) confirmed a
real honesty bug I had under-weighted: failedNoteEntries was a per-ENTRY set,
and renderPreviewRow checked it before the latest note. So with two notes on
one entry — the first append failing, the second persisting — the entry
showed both its ·note marker AND "the note could not be saved", masking the
saved note with a stale failure. Contradictory and dishonest.

Move failure onto the note: state gains "failed", and a failed attempt is
kept in commit order rather than removed. The list marker and search skip
failed notes; the preview surfaces a failure only when the failed note is the
entry's LATEST. Showing the latest note's state is order-independent, so a
later success is never masked by an earlier failure — and the coarse
per-entry failedNoteEntries set is gone entirely.

All existing note contracts hold (pending dim, failed line + marker withdrawn,
retry clears). The new test fails on the old code (shows the masking failure)
and passes now. The review's other findings were checked against the actual
code (it had only partial context) and were already correct: identity-based
removal, clear-on-commit, [p] guarded at zero entries, scroll reset on search,
cursor clamped (never -1), mode exclusivity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-note failure model added last commit makes entryMatchesQuery skip
notes with state "failed" — an append that rejected never reached disk, so
finding a reading by its text would be a lie. That exclusion was unlocked.

Pin it: a failed note's text is not found; saved, pending, and disk-loaded
(stateless) notes still are. Mutation-checked: dropping the state guard lets
the ghost note match.

Verified this pass that the [g] dictionary jump (guards empty selection,
passes a copy of the changing positions), the [n] guard, and the notes.jsonl
sidecar (torn-line tolerance, ref/text validation, orphan drop — all already
tested) are sound. No defect found there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The TUI passes entries newest-first; the CLI streams them append (oldest-
first) order. computeJournalPatterns sorts internally, so they should agree —
but two spots leaked the caller's order into the output, so the same journal
could derive different patterns on the two surfaces:

1. Diversity entropy/concentration summed the per-hexagram counts in
   first-seen (caller) order; float addition isn't associative, so
   entropyBits/normalizedEntropy differed in the last digit between orders.
   Sum ascending (canonical, and lower rounding).

2. compareEntryTime returned 0 on a time-key tie, so the stable sort kept
   input order. Entries sharing an instant — legacy same-day readings without
   timestamps — then ordered differently per caller, and the transitions,
   Hamming drift, and recency accent read from that order diverged
   meaningfully (e.g. mean drift 3.13 vs 3.04). Break ties by cast content so
   the order is deterministic.

Found by stress-testing order-independence (forward vs reversed must be byte-
identical across 60 random journals) — a property the invariant fuzz never
pinned. Both fixes land it; the new test locks it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 400-journal fuzz checked totals, the field, the method partition, 兩儀,
topHexagrams, and the two moving-line distributions — but several conservation
laws between fields were unpinned. Add them (all verified to hold across 500
random journals first, so this locks correct behaviour, not a fix):

- diversity.distinctHexagrams === the non-zero field cells, and the
  method-marked distinct never exceeds it
- trigrams: upperCount + lowerCount === count (each cast spends one upper +
  one lower slot), count-descending, count >= knownCount >= 0, index 0–7
- 卦變 drift: mean/max in 0–6 lines, transitions === consecutive pairs
  (total − 1), and the distance histogram sums to every transition
- 時 phase-of-day: the four buckets sum to the timestamped subset, which
  never exceeds the total

Mutation-checked: a trigram tally that double-counts (count += 2) breaks
upper+lower === count and fails at seed 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pressing [enter] on a reading emitted openJournalReading with a string key
(timestamp or, for legacy readings, just the date), and the factory resolved
it with entries.find(e => e.timestamp === key || e.date === key). That key
isn't unique: two readings on one day without timestamps share a date, and a
date key even matches timestamped entries on that day. So [enter] on the
second of two same-day legacy readings replayed the FIRST — the wrong cast.

Carry the entry on the signal by reference instead (the entry objects are
shared between the scene's list and the factory's). The factory uses it
directly — no lookup, no ambiguity — and the fallback for an unresolvable key
is gone with it. Verified: selecting the second of two same-day legacy
readings (primary 29) now replays primary 29, not 1. Modern timestamped data
was already correct; this fixes legacy/no-timestamp journals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, not append order

Auditing the 'reading-time-as-identity' pattern across the journal (after it
caused two bugs — the patterns tie-break and the replay key lookup), this is
the last live instance: 'journal show <date>' resolved a day to a reading with
`for (entry of stream) if (entry.date === date) found = entry` — the LAST
appended same-day cast, not the latest by time. On an out-of-order or imported
journal that surfaces an earlier reading as 'the day's'.

Pick the max entryTimeKey instead, matching what 'journal list' already sorts
by. Verified: three same-day casts appended 20:00 → 08:00 → 14:00 now show the
20:00 one, not the 14:00 appended last. Mutation-checked.

Audit close-out — the other instances are sound: compareEntryTime and the
replay signal were fixed earlier; note attachment keys on a date ref too but
that ambiguity is inherent (a legacy note's ref IS a date) and its 'last
same-day cast wins' resolution is documented, not an oversight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ce per theme

The 觀象 field reads how often a hexagram has come up as brightness — rarer is
dimmer (○ ◦ ◐ ●). The tier test pins WHICH tone each frequency gets; this pins
the tones into the right order: dimmed < tertiary < secondary < primary in
actual relative luminance, for every theme (ink/bone/cinnabar/jade/river).

All five are monotonic today (verified: 0.02 → 0.12 → 0.29–0.35 → 0.61–0.75),
but nothing guarded it — a future palette or a tone tweak could invert the
gradient for one theme so a rare hexagram renders brighter than a frequent
one, a colour bug no mono test can see. Mutation-checked: brightening one
theme's tertiary past its secondary fails 'bone tier 2'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… visible)

The field shows all 64 hexagrams; unseen ones use `dimmed`. For the grid to
read as a FIELD of 64 rather than a sparse scatter of only the seen ones,
dimmed must sit just ABOVE the background — faint but present. Verified across
all five themes (contrast 1.17–1.38, quietly visible).

Extend yesterday's gradient lock to the full chain bg < dimmed < tertiary <
secondary < primary, so the same test now guards both: unseen glyphs don't
vanish into the background, AND the frequency gradient never inverts. (Also
confirmed the ◉ recency accent is a coloured pop distinct from primary in
every theme — red/green/blue against the neutral tiers.) Mutation-checked:
sinking a theme's dimmed to bg level fails 'bone step 1'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ation

Most terminals are 256-colour, not truecolour, so the field's truecolour tier
tones get quantised before they reach the screen. Two tones that rise in
luminance can still collapse to the same 256 index — a failure the truecolour
luminance lock can't see — which would drop a tier from the field's gradient
for the most common terminal class.

Verified all five themes keep four distinct 256 indices (and dimmed distinct
from bg, so unseen stays visible) and locked it. Mutation-checked: nudging one
theme's secondary onto its primary collapses them in 256 and fails 'bone
tiers'. (16-colour is coarser and degrades by design — seen hexagrams still
render, only the unseen backdrop fades; a rare legacy mode, left as graceful
degradation.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… order-independent

Two real findings from the external GPT-Pro review of patterns.ts:

1. (highest priority) The changingPositions normalization filtered p >= 1 &&
   p <= 6 but not Number.isInteger — the comment even assumed "any integer
   array" from the store. A fractional value like 2.5 survived, so
   changing.length could reach 7, indexing a non-existent moving-line bin and
   producing NaN counts/shares across the moving-line distributions. Add the
   integer guard (matching isCastShaped). The fuzz now injects 2.5/3.5 too, so
   its no-NaN/conservation invariants cover this.

2. field.recent (the recency accent) had its own raw "tkey >= recentKey" loop
   over the unsorted entries — not the chronological sort's content tie-break —
   so two readings sharing a time key (legacy same-day, no timestamps) marked
   different hexagrams depending on input order: a missed instance of the
   reading-time-as-identity class my own sweep didn't catch. Track the
   compareEntryTime-maximum instead. Exported compareEntryTime so the fuzz
   invariant and a new focused test assert field.recent against the same
   canonical order. Both mutation-checked.

Verified the review's other findings against the full code: malformed
primary/lines are defense-in-depth gaps the store already rejects
(isCastShaped), and the timeOfDay local-hour binning is intentional for a
local CLI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-auditing the reading-time-as-identity pattern after the external review
caught a missed instance (field.recent), I swept every 'latest/max-by-time'
computation. All are sound: the lastDate/lastCastDate maxima compare dates so
ties (same-day) resolve to the same value regardless of order; field.recent
now uses the chronological tie-break.

store.latest() is the one that resolves by APPEND order, not time — a cheap
tail-read. It's correct because the app only ever appends in cast order, so
last-appended IS the chronologically latest for app-written journals (only an
externally reordered/imported journal could diverge, where it parts from the
time-key ordering field.recent uses). That invariant was undocumented;
spell it out so a maintainer doesn't mistake it for a bug or 'fix' it into an
O(n) scan that regresses the common path. No behaviour change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verifying the cast-to-journal integration (does every real cast reach the
patterns? does a replay duplicate?), I found the persist gate
'if (!isPlay && !isReplay)' correct — every real auto/manual cast persists,
seeded casts skip the journal but update the cache, and play/replay persist
nothing. The seeded and quit-early branches were tested, but the REPLAY half
of the gate was not: a regression that let a replay persist would silently
duplicate a reading every time it was reopened (from the journal, or
'iching today'), and the patterns would double-count it.

Lock it: a source:'existing' run reveals the cast but leaves the journal
empty. Mutation-checked — dropping !isReplay makes the replay append and
fails the test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…same cast

Verifying the journal's data seams — both proved sound: openJournal re-loads
entries fresh each open (main.ts), so a reading cast this session shows up
when the journal reopens; and the cast→journal persistence is correct (real
casts persist once, replays don't duplicate).

One cross-surface property the yarrow test left unasserted: it checked the
cache holds the cast and the journal entry's METHOD, but never the journal
entry's CAST. 'iching today' (cache) and the journal must agree on the day's
reading — they share one cast at persist time. Assert the journal entry's cast
equals both the cast and the cache's, so the two stores can't silently drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… the top row

The note wiring keys off this.filtered[this.cursor] — the reading under the
cursor. The existing persistence test only had ONE entry, where filtered[cursor]
and filtered[0] are the same object: a wiring that hardcoded the top row would
pass it untouched. Confirmed by mutation — swapping filtered[cursor] for
filtered[0] left all single-entry tests green while silently misfiling every
note onto the newest reading.

Seed three readings on distinct days (the scene sorts newest-first), walk the
cursor two rows down to the oldest, and commit a note. Assert its ref is the
oldest reading's timestamp, not the top row's — and on reload that the note
hangs off the oldest reading alone. Pins the cursor->note-target binding the
single-entry case can't see.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A single-reading journal on a terminal shorter than the 4-row chrome rendered
'1/1 (NaN%)' in the preview row. The scroll indicator's 'does the list fit?'
guard computes viewportH = rows - 4; at rows ≤ 4 that goes ≤ 0, so a one-entry
list slips past the guard and the position percentage divides 0/(1-1) → NaN.
Reproduced at rows 2-4 (rows ≥ 5 were already clean); reachable because the
session passes stdout.rows straight through with no minimum floor.

Floor the viewport at one row — a viewport can't be shorter than a single row,
and one entry then has nothing to scroll, so no indicator shows at all. Apply
the same Math.max(1, …) floor enter() was missing on the list scroll, where
resize() and both patterns-scroll assignments already had it (the lone unfloored
sibling, which seeded the cursor-visibility math with a ≤0 height until the
first resize). Regression test renders a single entry at rows 2-6 and asserts
no NaN reaches the buffer; mutation-confirmed it fails without the floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…JK width

The preview row reserves the scroll indicator's columns, then truncates the
selected entry's content to fit. An existing test covers that for the IMAGE
preview source with ASCII text; the NOTE source and non-ASCII width classes
were untested. Emoji, ZWJ family sequences and wide CJK measure differently in
stringWidth — the exact slip behind the CJK continuation-cell and stray-fragment
fixes this code already carries — and a regression would bleed a glyph into the
indicator.

Drive a long emoji / ZWJ-cluster / CJK note on the selected reading with the
list overflowing so the indicator shows. Assert the position indicator renders
in full, nothing glues after the note's ellipsis, and no row overruns its width.
Mutation-confirmed: dropping the indicator-width reservation fails this test
(and its image-preview sibling). Probed the width edges first — degenerate cols
and the patterns pane were already clean; this seam is the one worth pinning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The journal has two independent readers of history.jsonl: stream() (readline,
forward — backs 'journal list' and the patterns pane) and latest() (readFile +
split, backward — backs 'iching today' and 'journal note'). readline breaks on
\n, \r\n AND a lone \r; latest() split on "\n" only. So an externally
lone-CR-delimited journal — old-Mac endings from a hand-edit or an import —
collapsed into one giant unparseable line in latest(), which returned null:
'iching today' reported an EMPTY journal while 'journal list' streamed every
reading in full. Silent, and alarming for a daily-anchor feature.

Found while self-reviewing the storage layer against the same divergence
hypothesis (H3) I'd sent for external review. Probed stream-vs-latest across
CRLF, BOM, trailing whitespace, embedded blanks, torn tails, trailing notes —
all already agreed; lone-CR was the lone break. Fix: latest() splits on
universal newlines (\r\n|\r|\n) to match readline, so both readers resolve
the same last entry whatever the newline style and the lone-CR journal's
readings are recovered rather than lost. Mutation-confirmed regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A new journal's first impression was 'No readings yet' alone in a large void —
the blandest of the app's empty states, while its siblings carry contemplative
texture ('no line has yet moved', 'too few readings yet to weigh against
chance', 'nothing answers'). Bring it up to that register: a dimmer line beneath
the state, 'what you cast gathers here' (所占聚於此), orienting what the space
holds. Purely descriptive — never a prod to cast, no streak, no efficacy claim.

It sits one row under the state, just above the footer, so on a sub-chrome
height it yields rather than overwrite the way out (without the guard the
centered footer splits it into 'what you[esc] backers here'). Test locks both:
the line shows when there's room and is dropped clean on a tiny screen;
mutation-confirmed the guard. Full en/zhHant/zhHans, language gate green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…imating

Reopening a past reading from the journal ([enter]) builds a CastScene and calls
skipToComplete(false), so it shows the reading at rest at once — not redrawing
the lines one by one, replaying the cast ritual you already watched. The
existing replay tests pin the scene type and the esc/q→back exit, but nothing
pinned the settled state: drop the skipToComplete call and they all still pass
while every replay silently regresses to a full re-animation.

Render the factory's scene and assert the resting footer ([enter] detail) shows
and the cast animation's controls ([s] skip / [space] pause) do not — verified
by probe that those footers distinguish settled from mid-draw. Mutation-confirmed:
removing skipToComplete(false) fails this test alone. Found auditing the
journal's [enter] destination, the oracle reading panel, which renders correctly
against classical sources (乾 line 1 'Hidden dragon. Do not act.', 泰 lines 3/5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he journal

makeDetailScene fires getHexagramHistory → setHistory so opening a hexagram —
[g] from a journal reading, or the dictionary — shows 'Cast N times (last …)',
your own history with that hexagram. Only the crash path was pinned (a corrupt
journal must not throw an unhandled rejection) and setHistory in isolation; the
HAPPY path — a real journal actually hydrating the count — was untested. A
broken wiring (wrong kw, a dropped .then, setHistory miswired) would silently
blank the feature while every existing test stayed green.

Seed three casts of hexagram 1 across different days plus an unrelated cast,
open the detail, and assert it hydrates castCount 3 (hexagram 1 only) and
lastCastDate 2026-03-28 (its most recent day, not the later 卦-2 cast).
Mutation-confirmed: feeding getHexagramHistory the wrong kw fails this test
alone. Found auditing the journal's [g] destination — the seam's marking, exit,
and crash-safety were already covered; this async hydration was the gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
iching journal show today resolves the 'today' keyword to localToday() and
surfaces that day's reading — the CLI side of the daily anchor. The journal
command has 40+ tests, but every show test used a literal date or 'latest';
the 'today' keyword path was never exercised, so a regression in it (the keyword
no longer resolving, or resolving wrong) would silently break the command while
all of them stayed green.

Two subprocess tests, seeding the date from the same localToday() the command
uses so the test and the spawned process agree: show today surfaces today's
reading (Hexagram 39, not an older day's), and on a day with no reading it exits
1 with 'No reading found for today' rather than surfacing yesterday's as today's.
Mutation-confirmed: breaking the 'today' branch fails the happy-path test alone.

Found auditing the CLI journal surface — list/patterns/note and show's date,
latest, day's-latest, torn-line and empty-cast paths were all already covered;
the today keyword was the one untested command path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…erify it

The test pressed home (scrolling to the top) before closing the pane, so the
close-handler's own scrollToTop ran on an already-top scroll — a no-op. Removing
that scrollToTop left the test green, so the 'reopen lands at the head, not
where you left off' behavior its name claims was never actually pinned.

Scroll back to the last page (6/6) before closing, so reopening exercises the
close-reset from a genuinely scrolled position. Mutation-confirmed: dropping the
close-handler's scrollToTop now fails this test, where before it passed.

Found auditing the remaining journal-key seams ([d] → browse, the live note
input vs the scroll indicator — both already covered correctly); this masked
assertion in the patterns close path was the one real gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cast flow persists (journal append + daily cache write) BEFORE it reveals
the reading. That persist was unguarded, so a read-only or full data dir — a
first run under an unwritable XDG_STATE_HOME, a read-only mount, a full disk —
threw straight past the reveal: the user finished the whole ritual (coins,
yarrow stalks) and never saw the hexagram they drew, the reading was lost, and
the app died with a stack trace at the moment of revelation.

Every other write in the app already degrades gracefully — settings saves are
best-effort, reflection notes carry a per-note failure model — the reading
persist was the lone exception, and the most important one. Wrap it: on failure
warn ('couldn't save this reading … shown but not recorded', deferred under the
alt screen and flushed on exit) and still reveal. Seeing the reading you cast
matters more than recording it, and the warning is honest that it wasn't saved.

Test blocks the journal path (parent is a file → ENOTDIR) and asserts the cast
still reveals with the warning; mutation-confirmed the unguarded persist crashes
it. Found auditing the journal's WRITE path for persistence-failure robustness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pro-vi and others added 23 commits June 20, 2026 11:11
dateInZone returns a machine-local YYYY-MM-DD in two spots — the "system"/absent-zone
early return and the invalid-IANA-zone catch fallback — each hand-writing
`${instant.getFullYear()}-${pad(instant.getMonth() + 1)}-${pad(instant.getDate())}`.
Extract a module-local localDate(d) beside pad; both return it.

Makes the two a must-agree: the system path and the defense-in-depth fallback now
format the local day identically by construction, rather than two copies that could
drift (a "system" date rendering differently from an invalid-zone date would be a
quiet inconsistency).

Behavior-preserving — identical expression. Gates: 1351 pass (full expect count),
typecheck clean (gated), language PASS, smoke 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fgColor and bgColor ran the identical quantization — none-guard, hexToRgb, then
truecolor/256/16 branches — differing only in the SGR base (38 vs 48) and the
16-color background's offset. Extract colorCode(hex, support, isBg); both delegate.

Makes the fg/bg quantization a must-agree: they now share the same rgbTo256/rgbTo16
thresholds and support-level branching by construction, instead of two hand-copied
ladders that could drift (a foreground and background of the same hex rendering as
different colors under 256/16). The fg/bg-specific bits — the base and the bg offset —
are the only parameters.

Behavior-preserving — verified across the case table (none/truecolor/256/16 × fg/bg);
the 16-color bg offset (+10, +100 bright) is unchanged. Gates: 1351 pass (full expect
count), typecheck clean (gated), language PASS, smoke 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The renderer cleared a row the same way twice — cursorTo(row, 0), resetStyle,
clearToEndOfLine — once before repainting a changed row, once when clearing rows the
shrunken buffer left stale. Extract clearRow(chunks, row); both call it.

The sequence carries a subtle, documented invariant: reset style BEFORE the clear,
because clearToEndOfLine erases with the current SGR background, so a leftover bg from
the prior row would paint the cleared span (Render review, H2). Centralizing it means
no future row-clear can reintroduce that bug by reordering — the rationale now lives
once, on clearRow.

Behavior-preserving — identical three-chunk sequence. Gates: 1351 pass (full expect
count), typecheck clean (gated), language PASS, smoke 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…reimpls

centerCol is the horizontal-centering primitive — Math.max(0, Math.floor((total -
item) / 2)). Two sites recomputed its formula by hand: centerPad's leftPad (in the
same file) and detail-renderer's glyph column (Math.max(0, Math.floor((ctx.cols -
glyphEntry.width) / 2))). Route both through centerCol so all horizontal centering
shares one offset formula and can't drift (centered text and a centered glyph would
misalign if the floors diverged).

Left as-is: browse-renderer's hint uses Math.max(1, …) — a deliberate ≥1 left-margin
variant, not centerCol's max(0). The vertical (row) centerings are a separate family
with their own guards (max(0)/max(1)/+startRow), no shared primitive.

Behavior-preserving — centerPad's guard (w < totalWidth) makes centerCol's max(0) a
no-op there; detail-renderer's was already max(0)+floor. Gates: 1351 pass (full expect
count), typecheck clean (gated), language PASS (detail-renderer's toSimplified audit
intact), smoke 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…impls

R127 routed centerPad and detail-renderer through centerCol but under-swept: its
completeness grep keyed on specific operands (ctx.cols, totalWidth) and missed seven
siblings — journal-scene's title/sep/empty/invite/hint columns (all Math.max(0, Math.
floor((maxW - stringWidth(X)) / 2))) and renderTooSmallNotice's two frame.width
centerings. All are centerCol(width, stringWidth(X)) exactly. Route them through it.

With this, a broadened grep confirms NO horizontal max(0) centering reimplements the
formula anymore. Left intentionally: the max(1) left-margin variants (browse hint,
intention top) and the vertical row-centerings (rasterize startRow), which centerCol's
width-named API doesn't fit.

Behavior-preserving — each was max(0)+floor of the same formula. Gates: 1351 pass (full
expect count), typecheck clean (gated), language PASS (journal toSimplified audit
intact), smoke 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
animation/easing.ts exports easeOut (1 - (1 - t) * (1 - t)); radial imports it, but
sand reimplemented the identical formula as a local easeOutQuad. Import the shared
easeOut and drop the local copy, so the two glyph animators' ease-out is one function,
not two byte-identical curves that could drift.

Behavior-preserving — same formula. A broadened grep confirms easeOut is now
single-sourced (no other reimpl). Gates: 1351 pass (full expect count), typecheck
clean (gated), language PASS, smoke 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
stream() and notesIn() opened their JSONL line reader identically — createInterface
over a UTF-8 createReadStream with crlfDelay: Infinity. Extract openLineReader(path);
both call it, so the line-reading config (the crlfDelay that makes a CRLF count as one
break, the encoding) lives in one place and the two readers can't split lines
differently.

Scoped deliberately: only the reader SETUP is shared. The fuller streamLines<T>
extraction (the for-await/parse/yield body) was left alone — stream's explicit
rl.close() at the limit and notesIn's swallow-on-read-error try/catch are subtle async
error/cleanup semantics a shared generator would shift, so the bodies stay per-caller.

Behavior-preserving — identical setup, error/iteration paths untouched. Gates: 1351
pass (full expect count), typecheck clean (gated), language PASS, smoke 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The home menu was declared twice: an `items` array (render — key, label, tone,
visibility) and a parallel `switch` in handleKey (behavior — key → signal, same
visibility conditions). The two had to agree by hand; a key shown without a handler,
or a condition that drifted between them, would be a silent inconsistency (a menu
entry that does nothing, or a working key that isn't shown).

Replace both with one MENU table — { key, msgKey, tone, signal, show? }. render()
iterates it (filtering by show, mapping tone → theme), handleKey() looks an item up by
key and returns its signal (honoring the same show). Now a shown key always has a
handler and vice versa, by construction.

Behavior-preserving — verified across the case table (c/p/t/d/j/s/q → same signals,
p/t the same devMode/todayCast conditions, tones the same theme colors); isCtrlC/escape
handling untouched. Gates: 1351 pass (full expect count, incl. home-scene tests),
typecheck clean (gated), language PASS, smoke 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reading a rendered row as a string — buf.getRow(row).map((c) => c.char).join("") —
was inlined twelve times across journal-scene and yarrow-scene tests, and a thirteenth
time inside bufferText itself. Add rowText(buf, row) to testing.ts (the terminal
analogue of freshTempDir); the twelve test sites call it and bufferText is now
rows joined by newlines.

Behavior-preserving — identical char-join; .trimEnd() at the few sites that had it
stays appended to the rowText(...) call. Gates: 1351 pass (full expect count),
typecheck clean (gated), language PASS, smoke 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
testing.ts's sceneCtx was extracted precisely to stop suites hand-rolling the
SceneContext shape ("a field addition lands in one place, not nine" — its docstring),
yet eleven sites across seven suites still built { cols, rows, done: false,
colorSupport, [language] } by hand. Route them all through sceneCtx(cols, rows,
colorSupport, language?); the calls are shorter, drop their `as SceneContext` casts /
`: SceneContext` annotations, and match the convention the other nine suites use.

Behavior-preserving — sceneCtx produces the identical shape (done: false always,
language omitted unless given). loop-lifecycle's { cols, rows } dims objects are left
alone (not SceneContext). Gates: 1351 pass (full expect count), typecheck clean
(gated), language PASS, smoke 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
R137 migrated the per-row getRow().map().join() idiom to rowText but stopped a level
too low, leaving three loose ends the broadened grep this round surfaced:

- loop-lifecycle had its OWN local rowText (a getCell loop — same concept, different
  shape; an R137 sibling-sweep miss). Its only uses were whole-buffer dumps, so they
  become bufferText(frame) and the local is deleted.
- Four whole-buffer reimpls — Array.from({length: height}, (_, r) => rowText(buf, r)).
  join("\n") — were exactly bufferText (journal's renderText helper + one inline, plus
  loop's two). Route them through bufferText.
- Adding rowText to journal's imports (R137) shadowed two local `const rowText` vars
  (the row found by date); rename them dateRow so the import and the locals stop
  colliding.

Behavior-preserving — getCell-loop and getRow.map.join concatenate the same row chars;
the Array.from forms are bufferText verbatim; the rename is local. Gates: 1351 pass
(full expect count), typecheck clean (gated), language PASS, smoke 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
journal-command and today-command each defined an identical seedJournal(dataDir,
entries) — map entries to JSON lines, writeFile history.jsonl — differing only in the
param type (HistoryEntry[] vs Record<string, unknown>[]). Hoist one copy into cli/
testing.ts beside freshTempDir; both suites import it. journal-command's writeFile
import was only that helper, so it's dropped.

The shared param is unknown[]: typecheck (gated) rejected Record<string, unknown>[]
because HistoryEntry has no index signature, so neither concrete type subsumes the
other — unknown[] accepts both and the entries are JSON.stringify'd regardless.

Behavior-preserving — identical lines-build and write. Gates: 1351 pass (full expect
count), typecheck clean (gated), language PASS, smoke 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cross-file helper scan that R140's seedJournal prompted found two more identical
local test helpers:

- utcToday(): string (new Date().toISOString().slice(0,10)) — byte-identical in
  today-command and hook-adapter. Hoist into cli/testing.ts beside seedJournal.
- mockOutput() (a { write, writes } sink) — byte-identical in diff-render and
  diff-render-sync, and a strict subset of terminal/testing.ts's existing mockStdout
  ({ write, writes, columns, rows }; DiffRenderer only touches write). Drop both
  locals and use mockStdout — reuse existing over a third copy.

(makeCast/makeEntry/makeCache/ctxFor also recur but are per-suite-tailored — different
signatures — so left alone.) Behavior-preserving — identical utcToday; mockStdout's
extra columns/rows are unused by DiffRenderer. Gates: 1351 pass (full expect count),
typecheck clean (gated), language PASS, smoke 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…h formula

braille.ts documents its own purpose — "packs a 2×4 dot grid into one codepoint at
BRAILLE_BASE + a 0–255 dot mask" — but that formula was inlined as
String.fromCharCode(BRAILLE_BASE + mask) in three animators (noise, sand, and dots'
local toBraille), and dots wrote it (plus its inverse) with a raw 0x2800/0x28ff
instead of the shared constant. Add brailleFromMask(mask) beside isEmpty; the three
sites call it, dots' toBraille alias is dropped, and dots' brailleValue range check
now reads BRAILLE_BASE. sand's BRAILLE_COUNT import was already dead — dropped.

Behavior-preserving — brailleFromMask masks with & 0xff, matching dots' existing
clamp; noise/sand masks are already 0–255 so it's a no-op there. Gates: 1351 pass
(full expect count), typecheck clean (gated), language PASS, smoke 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…LE + isEmpty)

The blank braille glyph U+2800 was scattered: compose.ts named it locally
(EMPTY_BRAILLE), three animators (sand, dots ×2, radial) wrote the "⠀" literal
raw, braille.ts's isEmpty hardcoded it, and two scene renderers (cast/glyph-renderer,
dict/detail-renderer) reimplemented isEmpty inline as
`chars[c] === "⠀" || chars[c] === " "`.

Export EMPTY_BRAILLE from braille.ts (the primitives module) and route everything
through it: isEmpty reads the constant, compose imports it instead of its local copy,
the animators write EMPTY_BRAILLE, and the two renderers call isEmpty(chars[c]) — the
predicate they were duplicating. No raw U+2800 survives outside braille.ts.

Behavior-preserving — same literal, same predicate. detail-renderer's change is a
blank-cell check, not a language surface, so the toSimplified audit is unaffected
(verified). Gates: 1351 pass (full expect count), typecheck clean (gated), language
PASS, smoke 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ess)

R145 routed every inline blank-braille check through isEmpty, but its completeness
grep searched for the "⠀" escape and missed settings-scene's preview loop, which
wrote the check with the literal glyph: `ch === "⠀" || ch === " "`. Same predicate,
different source representation. Route it through isEmpty(ch) like the others.

Lesson banked: a unicode literal can appear as an escape OR the raw character — the
completeness grep must cover both (cf. R128's operand-agnostic sweep). No inline
blank-cell check now survives in either form.

Behavior-preserving — same predicate; a blank-cell check, not a language surface, so
the toSimplified audit is unaffected (verified). Gates: 1351 pass (full expect count),
typecheck clean (gated), language PASS, smoke 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… lookup

normalizeConfig resolved a legacy castMode string into the new castMethod+castMode
pair in two places — the "castMode set, castMethod absent" branch and the nested
"castMode not a current option" branch — with a byte-identical four-line block
(Object.hasOwn(LEGACY_CAST_MODE,…) guard + conditional assign of both fields). If
legacy-mode handling ever changes, both copies had to change together.

Extract applyLegacyCastMode(merged, rawCastMode) beside LEGACY_CAST_MODE; both branches
call it. Behavior-preserving — same guard, same assignments; the config-store tests
cover the legacy-migration paths. Gates: 1351 pass (full expect count), typecheck clean
(gated), language PASS, smoke 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two locale paths — mapLocaleToken and the LC_ALL/LANG resolution — both did
`s.split(/[.@]/)[0]` to drop a locale's .CODESET and @modifier suffixes down to
lang_TERRITORY. The regex is cryptic out of context (why `.` and `@`? — POSIX locale
syntax), and the two had to agree on the separators. Extract localeBase(locale) to
name the operation and the pattern; both call it.

Behavior-preserving — identical split; split always yields at least the whole string,
so [0] is safe as before. Gates: 1351 pass (full expect count), typecheck clean
(gated), language PASS, smoke 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
moveCursor bounded the cursor with Math.min(Math.max(0, cursor + delta),
lastIndex(...)) — a hand-written clamp, the one site still doing so after clamp was
adopted everywhere else. delta is ±1 so both bounds are real; since lastIndex is always
≥ 0 this is exactly clamp(cursor + delta, 0, lastIndex(...)). Use it (already imported).

The page up/down pair stays as-is on purpose — those are one-sided bounds (up floors at
0, down caps at lastIndex), since each direction can only overflow one way; a clamp
there would add a never-hit bound. Behavior-preserving — verified the nested form equals
clamp for a non-negative upper bound across all cursor positions. Gates: 1351 pass (full
expect count), typecheck clean (gated), language PASS, smoke 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lding #RRGGBB

color/hex.ts had hexToRgb but not its inverse, so lerpColor emitted its interpolated
result by hand — `#${r.toString(16).padStart(2,"0")}${g…}${bv…}`, the hex-byte format
written three times in one expression. Add rgbToHex(r, g, b) beside hexToRgb (the two
now bookend the conversion in one module) and let lerp return rgbToHex(r, g, bv).

Behavior-preserving — same per-channel toString(16).padStart(2,"0"); lerp's channels
are already byte-clamped (toByte) so the hex is clean. Gates: 1351 pass (full expect
count), typecheck clean (gated), language PASS, smoke 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ving it

journal-scene held a local padToWidth (stringWidth-aware right-pad, with a doc
explaining why String.padEnd mis-measures CJK/glyph cells), and browse-renderer
inlined the same op by hand — chinese + " ".repeat(Math.max(0, fixed - stringWidth)).
Move padToWidth into layout/measure.ts beside centerPad/truncateToWidth (both files
already import from it); journal imports it, browse calls it and drops its now-redundant
chineseWidth local.

Behavior-preserving — same stringWidth-based right-pad; browse's Math.max(0,…) guard is
exactly padToWidth's `w >= width ? text` early return. A layout helper, not a language
surface, so the toSimplified audit is unaffected (verified). Gates: 1351 pass (full
expect count), typecheck clean (gated), language PASS, smoke 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
renderList computed its visible-row count as raw "ctx.rows - 4", while every other viewport in the scene (enter/resize, the fits-guard) routes through the floored viewportHeight(rows, chrome) helper. On a terminal of 4 rows or fewer the raw form went to 0 or negative, so the render bound disagreed with the scroll state's floored value and the list body rendered empty. Route line 354 through the same helper; no behavior change above 4 rows, since Math.max(1, rows-4) is a no-op there.

Surfaced by an independent review; the loop's own R112 viewportHeight sweep missed this site.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
discoverLegacyPaths only stat-detected ~/.claude/iching.json and .jsonl; it was exported and had a test, but no production code ever called it and there was no companion copy/migrate routine, so even if wired it would do nothing. Remove the dead surface (legacy/discovery.ts), its two barrel exports, its test (which did not even call it), and the SPEC evidence line. Storage resolution stays override/ICHING_HOME/XDG; typecheck clean and the storage suite is green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@pro-vi

pro-vi commented Jun 20, 2026

Copy link
Copy Markdown
Owner Author

@codex review

The branch has advanced well past your last reviewed commit (6388ddde); the current tip is 608245a9 (a large consolidation/refactor pass plus two small fixes). Please review the updated diff for any issues.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: 608245a97f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

pro-vi and others added 4 commits June 20, 2026 15:45
A 10-agent audit graded the suite 7A/3B; this fixes the two high-severity holes plus the clear medium/low ones — turning green-no-matter-what tests into ones that pin behavior (the flagship was mutation-verified: removing the code under test flips its output and the test goes red).

- diff-render: add the only test that feeds present() a wide (CJK) char, pinning the load-bearing continuation-cell skip (deleting it flips the output to "世 a").
- doctor: replace the tautological color test (it re-read process.env and asserted typeof, never invoking checkColor) with three subprocess tests that drive the real check under a clean controlled env (truecolor / 256-color / NO_COLOR).
- timeline-builder: the two subtitleText=='' tests asserted the constructor default; pre-dirty so they pin the unchanging-hold reset vs the changing path leaving it untouched; the morph test now asserts the becoming morph completes.
- detail-scene: the two expect(true).toBe(true) crash tests now assert the rendered buffer carries each hexagram's name (all 64).
- journal-patterns: pin the Poisson residual and Simpson concentration/maxEntropyBits (only their empty-journal zeros were checked).
- yarrow: add the never-exercised rejection-sampling branch (a byte past the acceptance limit must be rejected, not folded with % — modulo bias).
- atomic-write: add the real durability guarantee — a failed write over an existing file leaves the original intact (read-only-dir fault).
- journal-command: rebuild the control-byte fixture from String.fromCharCode so ESC/BEL can't be normalized out of the source.
- browse-scene / text-input / key-parser-ext / cast: assert rendered content, caret position, exact event arrays (no leaked event), viewport-tracking, and rename a mislabeled 'cast command' describe that runs no subprocess.

Gate green: bun run test 1353/0, typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
detail-scene set model.viewportHeight from raw `rows - FOOTER_ROWS` at both its enter()
and resize() paths — the cross-file siblings of journal's already-floored renderList.
Route both through viewportHeight() so every viewport calc in the dict scenes obeys the
same Math.max(1, …) idiom.

Behavior-identical at every renderable size — the "window too small" guard keeps
rows - FOOTER_ROWS > 0 whenever the scene draws, so the floor is a no-op there; it only
hardens the unreachable degenerate edge. Gates green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
endsMidLine rethrows a non-ENOENT open() failure (EACCES/EISDIR), so journal append
crashes on a read-only / permission-denied file — while json-config and json-daily-cache
reads degrade on the same errno (warn + return null/"corrupt"). FIXME added at the
decision point; the fix (return false to match the read contract) is a behavior change
left for a deliberate pass. Surfaced by the R147 refactor sweep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Release-engineering prep surfaced by the /second-opinion pre-ship gate (GPT Pro extended consult + local clean-room verification):

- Bump root, apps/cli, and publish manifest to 0.5.0. @pro-vi/iching reads the root version at publish; iching --version reads apps/cli — bumping both keeps the published package and its self-reported version in lockstep.
- publish engines node >=18 -> >=20: commander@^14 requires Node 20+, so the prior >=18 claim was inaccurate (bundled, it would surface at runtime on Node 18).
- Add TEXT_SOURCES.md and ship it in the npm tarball (release.yml cp + files): documents the classical Chinese (public domain) + Legge gcEn (public domain) + the original Wilhelm-register gcEnW/yaoEn renderings (AC-010 policy: original wording from public-domain sources, not licensed Wilhelm-Baynes text), and the MIT carveout (code + original material, not third-party texts).

Verified: bun run test 1353/0, typecheck clean; clean-room global install of the exact 0.5.0 tarball reports --version 0.5.0, runs cast under node, ships 5 files (incl. TEXT_SOURCES.md) with a node shebang, no local-path/secret leaks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@pro-vi pro-vi merged commit 4be24a3 into main Jun 21, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant