The 16 Sep lib fixed both defects gloss.ts used to work around: a gloss
block closes at each "=" line, and every block is collected. So the
sentence splitting goes.
Three defects remain in how a block ENDS, measured on the new lib:
· deleting one block still deletes the "::" of the next, so a whole
::words block reaches the prose as "words" plus its rows;
· an unclosed gloss still swallows the following paragraph as parts;
· two gloss blocks written back to back share one terminator, and lib
never sees the second.
The old rebuild also only re-parsed the FIRST gloss block, which against
a lib that accumulates would have dropped every block after it.
One pass over the raw text now yields both the prose and each gloss
block's content, a gloss block ending where its rows end; lib parses each
block on its own, so the row format is still entirely lib's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The artifact was reworked after real incidents: a week of lost data, a
student taught out of order, and spelling diagnoses the model invented.
This takes the new export in verbatim; the port catches up in the
commits that follow.
Copied byte-identical from the bundle:
lib/ lexicon.js and sync.js are new; gate.js gains enforcement,
hangul.js letter-level marking, srs.js recall evidence,
conjugation.js deconjugate(); blocks.js now takes the last
block, closes gloss at "=", and parses recall, ::result and
::confirmed
data/ curriculum.json v5 — six 다지기 phase reviews; the 371
roadmap words are unchanged and no band moves
prompt/ English-only rule, recall, LETTER-LEVEL CHECK, marking
audit-gate.mjs, run-checks.sh, fixtures/ — the word gate measured
against 54 real tutor messages
CI runs run-checks.sh in place of validate.mjs alone, and `npm run check`
gains the audit. Baselines: validate PASS 0/0; audit 7 of 41 and 2 of 13.
types/lib/ declares the new API, and test/lib/ pins it: letterCheck on
the prompt's own 짧다/빫다 case, deconjugation, the roadmap-first order
that keeps 마셔 out of Phase 1, sync's three gates, and recall evidence —
including the two ways lib's evidence is looser than PORT.md, pinned as
they are so the call site that tightens them is visibly needed.
TaskHost gains a plain recall renderer so the tree typechecks against the
wider Task union.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported again: "::task translate" and its four lines showing as text above
the exercise they had been rendered into. The previous fix dropped
everything from the first line beginning "::", and could not see this one,
because by the time it ran the colons were gone:
**Example sentence**
*나 바다*
task translate <- the "::" removed, the items left as prose
나 바다
lib/blocks.js removes each block by string surgery, and a block's
terminator `(?:\n::|$)` is inside its own match, so removing one block
takes the two colons belonging to the NEXT one with it. Last time the
casualty was ::task before ::words; this reply put ::gloss first and the
casualty was ::task. Repairing that body cannot be made to work in general.
So the body is no longer repaired, it is derived: proseOf() reads the RAW
text, where the markers are always intact, and keeps every line outside a
block. The grammar is small -- a line of exactly "::" closes, any other
"::" line opens, a block runs until closed, until the next opens, or to the
end -- and nothing compares exact strings, because this model ends every
line with markdown's two trailing spaces.
An unclosed ::gloss is handled too, and every local model tested forgets
that closer. lib then reads the following paragraph as gloss parts, so two
sentences of English rendered as Korean example text inside the card. A
gloss row is `한글 | English | note` and a translation starts with "=";
anything else ends the block. The stranded prose goes back into the
message, and the gloss is now rebuilt unconditionally rather than only for
multi-sentence blocks, since the single-sentence case is exactly where this
bites.
Separately, MessageBody degrades gracefully on markdown instead of showing
it raw. The prompt forbids all of it and says so outright, but the backend
is pluggable now and a local model ignores the rule: a bulleted list of the
ten consonants arrived as lines starting with a hyphen, "*What we learn:*"
kept its asterisks, "---" showed as three dashes. Bullets, headings, rules
and *italic* now render quietly.
Verified by serving the exact reported reply through the real app: 10
bullets rendered as rows, no literal markup, no directive text, the gloss
card holding only 나 | 바다 = "I sea", and the exercise as real UI.
Also worth recording: three browser checks in this session were reading a
stub-generated turn synced down from Postgres, not the model. The app boots
on 수업, so the local stand-in writes the opening turn before a server can
be configured, and a fresh client then pulls the old transcript. Sending a
message after connecting is what actually exercises the remote path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
assemblePrompt used template.replace("{{GATE}}", ...), and String.replace
with a string argument substitutes only the FIRST occurrence. All three
placeholders appear twice in tutor-system.md, because the document names
them in its own header paragraph before using them:
Assembled per turn. `{{GATE}}` is `renderGate()` from `lib/gate.js`;
`{{VARIETY}}` and `{{FOCUS}}` are one-liners built from recent state.
So the rendered gate replaced the backticked mention mid-sentence, and the
real slot further down was sent to the model as the literal text "{{GATE}}".
The mechanism that decides what the tutor is allowed to teach was delivered
in the wrong place, with a template token standing where it belonged, and
the same for VARIETY and FOCUS. Confirmed by capturing what the app
actually put on the wire: three unfilled placeholders at lines 60, 86 and
141.
Substitution is now anchored to a whole line, which is what distinguishes a
slot from a mention -- the header's are inline and backticked. The
replacement is a function because renderGate() output contains "$"
sequences that String.replace would otherwise interpret.
The existing tests could not have caught this. They ran against a synthetic
template naming each placeholder exactly once, which is precisely the
property the shipped file lacks. The new ones run against
prompt/tutor-system.md itself: no slot may survive unfilled, the header
must come through intact, and the gate must land between the profile and
the pre-flight check. Both fail against the old code.
Also appends a HOUSE STYLE section after the shipped prompt -- an addition
by the app, not an edit to the file, which still ships byte-identical.
It covers two things the prompt leaves to inference. The language of
explanation is never actually stated: "한글 and English only" is a rule
about retiring romanization, and everything else only implies English. A
strong model infers it; gpt-oss-20b delivered a full grammar lesson in
Korean to a student on unit 1.1 who cannot yet read it. And no-markdown is
stated outright at line 96 and was ignored anyway, so it is restated where
the consequence is visible: the app renders **bold** and nothing else, so a
table arrives as rows of literal pipes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported from the app: "::task translate" and its five sentences appeared
as text in the message, directly above the exercise those same lines had
been rendered into.
It is lib/blocks.js, not the model. parse() removes ::words by truncating
the body at its index, then removes ::task by substring:
if (w) body = body.slice(0, body.indexOf("::words"));
if (t) body = body.replace(t[0], "");
RE.task's terminator (?:\n::|$) is part of the match, so t[0] ends with the
"\n::" belonging to the ::words that follows -- the two colons the line
above just truncated away. The substring no longer occurs, replace() is a
no-op, and the whole task block stays in the body.
Order is the whole trigger. The stub tutor emits ::words before ::task and
is therefore fine; the local model emitted ::task first. Nothing in the
prompt requires either order, so this was always reachable -- Claude would
hit it too. It survived every test until a real model chose the other way.
lib/ ships unchanged, so the fix is at the call site, next to the ::gloss
workaround that is there for the same reason: parseMessage() drops the body
from the first surviving directive line on. Safe precisely because parse()
has already removed the blocks it handled correctly, so a "::" still in the
body is by definition one that leaked. That also subsumes the streaming
filter added earlier, which is now one rule instead of two.
A turn can also be nothing but blocks -- this model writes no prose around
an exercise at all -- which left an empty bubble above it. The bubble is
skipped when there is nothing to put in it, and the typing dots stay up
while the reply so far is only markup, since there is genuinely nothing to
read yet.
test/domain/block-leak.test.ts pins the lib behaviour as it is, and the
workaround against a verbatim capture of the model output that produced the
report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The seed pre-marked the artifact's 30 headwords secure on first run. Two
reasons it is gone rather than merely disabled:
It was never 30 cards. The match was on headword, and homographs each
carry their own lemma, so 그 as pronoun and as determiner both matched —
47 rows for a 30-word list.
Worse, "Reset everything" deleted the cards and then deleted the
'seed.known' guard along with the other meta keys, so the next boot
re-seeded and the deck looked untouched. The one thing a wipe exists for,
undone by the wipe itself.
Migration 5 clears the seed from installs that already have it. seedCard()
is the only writer that leaves updated_at = 0 on a card and the seed was
its only caller, so `DELETE FROM card WHERE updated_at = 0` removes exactly
the seeded rows and nothing the learner graded — the timestamp rule paying
for itself a second time.
test/domain/reset.test.ts pins both scopes: what a full wipe must leave
empty, what a roadmap reset must keep, that the deletions are tombstoned
so a sync cannot restore them, and that migration 5 really runs against a
schema-4 database rather than the test performing the delete itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
React + Vite + TypeScript, PWA, offline-first. Six tabs: 수업 오늘 단어 문장
문법 한글, plus the full-screen SRS review overlay, the reading drill, the
conjugation trainer and the 두벌식 keyboard.
The visual language is carried over deliberately: two hand-tuned palettes,
three type stacks, about a dozen component classes, zero border-radius and
no icons anywhere — Korean glyphs do the work icons would.
THE GATE is the reason this app exists. buildGate() already took a
vocabQuery hook; filling it with a band query is what turns 371 hand-typed
words into something that scales. Three refinements sit inside that hook,
all of them narrowing:
1. words a not-yet-finished unit is the first to introduce are excluded,
so a frequency ceiling cannot smuggle 3.4's material into 2.1;
2. Phase 1 is filtered by the phonological ladder;
3. the list is capped at 800 by frequency, because renderGate() inlines
it into the prompt — strictly more restrictive than the band, so it
cannot leak.
prompt/tutor-system.md ships unchanged with {{GATE}} filled by renderGate().
Confidence is clamped per turn. The artifact wrote the model's ::progress
number straight into the sole gate on advancement, so one hallucinated 95
skipped a unit.
stub-tutor.ts stands in for the model on the artifact's exact contract —
onText receives cumulative text, an aborted turn keeps what it streamed —
so the real endpoint drops in without touching the UI. It rotates all four
task types and climbs progress gradually, which makes every render path
reachable with no server.
Two artifact bugs are not ported: task state lived in the full-page
re-render, so anything arriving mid-answer wiped typed text and placed
chips; and the day number was computed once at module load, so a session
left open overnight scheduled against yesterday.
Verified in a browser: all six tabs work, and after a hard reload with the
network cut every tab still works — including dictionary search out of OPFS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes REVIEW.md §2. 167 of 371 roadmap words had no lexicon entry, so the
word rail silently showed nothing. Now:
Roadmap words: 371/371 resolve
Spiral targets: 35/35 resolve
Deck words: 386/386 resolve
and npm run dict:assert makes it a blocking build failure, not a silent
empty rail.
No runtime morphological analyser ships. lib/conjugation.js surfaceForms()
runs at BUILD time over every verb and adjective, so looking up a conjugated
form is an index hit on the surface table.
The frequency join had to be inverted. A subtitle frequency list holds
surface forms; a dictionary holds lemmas whose -다 citation form barely
occurs in running text, so joining on headword gives verbs a frequency of
roughly zero. Expanding each lemma into the forms it plausibly takes and
summing recovers 하다 from 118 to 89,041. Forms claimed by more than one
lemma are dropped rather than split, so homographs don't inherit each
other's mass. Those expansions score frequency only — the surface table
itself stays strictly surfaceForms() output plus the headword.
Bands are one per curriculum phase. Phase 1 admits no frequency band at
all: during the writing-system phase every word must be phonologically
legal for the unit reached, and a rank ceiling would hand the learner a
겹받침 during unit 1.4. shared/phonology.mjs lifts validate.mjs's own
feature ladder to enforce that; it agrees with the validator on all 371
words.
Sources are chosen automatically — KRDICT when vendored, otherwise the
kaikki.org extract. KRDICT's download is a JS form behind anti-bot
protection, so it cannot be fetched by CI; the derived band files are
committed instead, which the app needs offline regardless. Attribution and
the share-alike terms are in NOTICE.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>