feat: reading depth — canonical oracle texts, daily anchor, journal instrument, bound entropy#9
Merged
Conversation
…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>
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>
Owner
Author
|
@codex review The branch has advanced well past your last reviewed commit ( |
|
Codex Review: Didn't find any major issues. Chef's kiss. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
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.←/→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
[t]to reopen today's reading (stale-safe across midnight);escon Home is a no-op instead of killing the session.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
[n]in the TUI,iching journal note), persisted to anotes.jsonlsidecar so journals stay readable by the shipped 0.4.0 binary; honest pending → saved | failed persistence with in-flight writes drained on exit.[/]search (intention text, names, pinyin, number),[g]jump to the entry's hexagram with moving lines marked, j/k + home/end parity.[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/coreand exposed on the CLI — a calm one-screen plain digest, and a--jsonsurface that resolves hexagram numbers to name blocks and namespaces every method-marked comparison under its owncomparisonblock (with a top-levelbasisnote) 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);--seeduntouched.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.Terminal & Input Foundation
Storage Robustness
skippedLinessurfaced injournalstderr notes and a newdoctorintegrity check, cast-method provenance, config positional shorthand,--seed/--limit/--sincevalidation, control-character sanitization for note text at write and render.Visual & Docs
bun run smokeworks 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+/refactorpasses — produced a layer of correctness and consistency fixes:One canonical reading everywhere: extracted
readingTextsas 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 (WilhelmgcEnW, the displayed default) with the verbatim-LeggegcEnkept as the scholarly anchor;journal showsurfaces the same reading as the fresh cast, static 卦辭 included.Read-time cast integrity:
isCastShapednow 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;entryTimeKeytrusts 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:
--jsonnow 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 withbasename().Test & tooling: a shared
@iching/core/testingcast-fixture builder (ends the fixture lockstep the integrity check introduced), end-to-endpaths/hexagramcommand 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 singleresolveTodayReadingresolver (cache-first, journal-fallback) behind bothiching todayand 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) CLInote --datewrites 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 indocs/process/durable-recovery.md.Patterns module decomposition: the 652-line
patterns.tssplit by concern intopatterns/chance.ts(observed-vs-expected model),patterns/time.ts(temporal keys & day-phase), andpatterns/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:/castingcame back clean (zeroas any/suppressions; everyJSON.parseis parsed-then-validated at the storage boundary);/namingrenamed one I/O verb (getHexagramHistory→loadHexagramHistory, matching itsload*sibling). Then an 84-agent review fan-out (/code-review+/failure-mode+/invariance+/perfover 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:history.jsonlon 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²).structurevalidation is deep (a half-shaped record no longer rendersUpper: undefined);entryTimeKeycanonicalizes to UTC so a zone-offset stamp sorts chronologically; the notes-path invariant is pinned by a test.resolveTodayReadingre-warms the cache on journal recovery; the legacy bare-date-note and seeded-cast edge cases are reconciled indocs/process/durable-recovery.md.config.timezonewas settable but never consumed —localToday()and the 時 phase-of-day binning now honor the configured zone (machine-local for"system") via a purecore/zone.ts.journal listfull-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):length > 1and printed a shortlist instead of opening the hexagram (乾: 1 hit → 15). Fixed: the resolver treats a unique score-0 hit as the answer, andsearchHexagramsScoredkeeps the family for the dictionary browse filter. Caught only by the cross-model pass — the in-tree personas judged the family scoring correct withinsearch.ts; the regression lived across the module boundary in the resolver's length check.date(P2) — the journal/notedaterendered raw whileintention/note.textwere already stripped, so an imported/hand-edited record could replay ESC/OSC control bytes onjournal list|showandtoday. Fixed at the parser, covering every render surface.[s]skip restarted the glyph animation instead of settling it (nowskipToComplete(false)); the virtual clock credited the entire hidden gap after a too-small resize, amplified by pace (now advances by the clampeddt, like yarrow); the centered becoming-title overlapped the reading panel on tall-narrow terminals (suppressed while the reading shows).feed.--allno 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>is the Meta/Alt-prefix convention (a real Escape arrives as a loneESC), as documented and tested.qian→乾/謙,kun→坤/困) and the English image words were already ambiguous onmain, 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 withinsearch.tswhile 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
bun run test), typecheck clean, language-surface gate PASS — 0 issuesbun run build && bun run smoke)iching today --jsonstable key set in both states; hook run preserves rng provenance--seed abcexits 1 in cast, TUI, and hook modes;--seed 42deterministicRelated
Generated with: Claude Code
🤖 Generated with Claude Code