Status line: is the linguexx suite green for the tree as it stands, or stale and why

A standalone LaTeX package for linguistic examples, with a linguex-compatible input syntax and first-class support for accessible (tagged) PDF output.
linguexx reimplements the familiar dot-syntax of linguex (\ex., \a., \b., \z., \exg., \gll, \glt, \Next, \Last, …) on a fresh expl3 engine, with no dependency on linguex or cgloss4e. It runs on pdfLaTeX, XeLaTeX and LuaLaTeX. A [legacy] option reproduces linguex's exact geometry for drop-in replacement; the default mode is a slightly tighter variant.
Two other input syntaxes are available as options and drive the same engine, so they share one counter, one label system and all layout parameters with the dot syntax: [gb4e] for the exe / xlist environments, and [langsci] for the \ea … \z front-end of langsci-gb4e. Loaded alongside [lazy], either lets a document move over one example at a time. [langsci] provides what langsci-gb4e does — the criterion is what works there, not what its two documents describe — including \eal … \zl, the sub-list numbering variants, the box and font commands and its four package options. It also takes that package's reference convention: under [langsci] a plain \ref prints 1 rather than (1), as langsci-gb4e does, and \ExParenRefs asks for the other one. The two names that have never worked upstream (xlistabr, qlist) are refused by name, with an error saying what to write instead.
When the document enables the LaTeX tagging code, linguexx writes its examples into the PDF structure tree as genuine, accessible objects:
L → LI → Lbl → LBody, sub-levels nested, with valid per-level /ListNumbering (decimal / lower-alpha / lower-roman);/Alt), so a screen reader says "ungrammatical" rather than "asterisk" — see \DeclareJudgment[spoken=…];\GlossTierLang{1}{de} → /Lang);\GlossTransSide) keeps its place in the reading order, grid first;\exannot{[CP]}) carry a spoken form too (\SetAnnotSpoken → /Alt), so a reader hears "complementizer phrase" while the page still shows [CP] (and \ExAnnotFit puts that column where the examples themselves say it belongs, one column per example);\mvto{a}{What} … \mvfrom{a}{\_\_}) are drawn as artifacts, and the base position is read with a note, "__ (moved)" (\SetMoveSpoken sets the word), so the arrow's meaning reaches a reader who cannot see it;\lpzg{sg} → /E "singular"), so they are spoken in full while the page still shows SG, and \lpzglist prints the list of those actually used, as a tagged list;Link elements with their annotation under them: \ref as always, and with it \Next, \Last and the rest of the relative references, which link to the example they name without needing a label — and never to one the document does not have.See doc/TAGGING-NOTES.md for the technical account and doc/PDFUA-CHECKLIST.md for turning a document into a PDF/UA-conformant build.
\documentclass{article}
\usepackage[lazy]{linguexx} % or [legacy], [gb4e], [langsci], combinations
\begin{document}
\ex. A first example.
\a. a sub-example
\b. *a judged sub-example
\exg. Der Hund bellte.\\
the dog barked.\\
\glt `The dog barked.'
\end{document}
For accessible output, add a \DocumentMetadata line before \documentclass; the simplest current form is
\DocumentMetadata{lang=en, tagging=on}
The full manual is linguexx-doc.pdf. Rebuild it with lualatex, not pdflatex: it documents (§9.3) that characters with a diacritic below the letter — Indic and Semitic transliteration, Latvian, Romanian — get a broken text layer under pdflatex, and it contains those characters in the table that explains it. The manual refuses to build with pdflatex for that reason.
Put linguexx.sty where LaTeX can find it — the working directory for a single project, or TEXMF/tex/latex/linguexx/ (then texhash) for a system-wide install.
The regression suite checks example geometry (via pdftotext -bbox) and, for the tagged cases, the PDF structure tree, across all three engines:
cd tests
python3 runtests.py # all cases, all engines
python3 runtests.py -k tagged # one case
python3 runtests.py -v # show every assertion
python3 runtests.py --documents # ... and the manual and the examples too
--documents is the rest of the gate: it builds the manual and the shipped examples and validates examples/ua-demo.pdf with veraPDF, which is what the ua case cannot do for you (it deliberately leaves footnote examples out). It is what CI runs.
It needs the three TeX engines and Python 3, plus poppler-utils (pdftotext for the word boxes every geometric assertion reads, pdfinfo for the structure tree), qpdf (to resolve a named destination to the page it lands on, which no poppler tool reports), show-pdf-tags (the LaTeX team's own tag viewer, the TeX Live package of that name: it resolves /K the way a consumer does, which is what sees an /Alt that wraps nothing) and veraPDF on PATH as verapdf, which needs a JRE — it is the only authoritative oracle for PDF/UA. The suite will not start without poppler or qpdf, and the tagging and PDF/UA cases fail rather than quietly skip when show-pdf-tags or veraPDF is absent. That list is REQUIRED_TOOLS in tests/runtests.py, which is checked against PATH, the suite's own docstring and both CI definitions rather than being kept in step by hand.
expl3 (part of the LaTeX kernel).tikz, which draws the brace of \altn and \altg so that the alternatives are ordinary tagged text rather than a formula, and the movement arrows of \mvfrom/\mvto (with its arrows.meta library), and xspace, for the space after \Last and its family. Both are loaded by the package itself.LaTeX Project Public License 1.3c or later — see LICENSE.
Gerhard Schaden \& Claude (Fable, Opus, Sonnet).
hooks/register.ts 63 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// The verdict is `lxx stamp --brief`'s, never this file's: the hash the
4// commit hooks compare lives in .claude/tools/lxx, and a second copy here
5// would be the drift that harness exists to prevent.
6const LXX = '.claude/tools/lxx'
7// Edits made outside the session (Emacs) raise no event, so poll as well.
8const POLL_MS = 30_000
9
10// The repository root once session.start has found the harness there.
11let root: string | undefined
12let running = false
13
14async function refresh($: EngineInterface): Promise<void> {
15 if (root === undefined || running) return
16 running = true
17 try {
18 const { exitCode, stdout } = await $.process.run(
19 [`${root}/${LXX}`, 'stamp', '--brief'], { cwd: root, timeoutMs: 10_000 })
20 const line = stdout.trim().split('\n')[0]
21 $.ui.status(line ? `${exitCode === 0 ? '✓' : '✗'} ${line}` : undefined)
22 } catch {
23 $.ui.status('✗ lxx: stamp check failed')
24 } finally {
25 running = false
26 }
27}
28
29async function locate($: EngineInterface, cwd: string): Promise<void> {
30 try {
31 const st = await $.fs.stat(`${cwd}/${LXX}`)
32 root = st.kind === 'file' ? cwd : undefined
33 } catch {
34 root = undefined
35 }
36}
37
38export const register: Register = on => {
39 root = undefined
40 running = false
41
42 on('session.start', async ($, e, next) => {
43 const done = await next(e)
44 await locate($, e.cwd)
45 if (root !== undefined) {
46 await refresh($)
47 $.clock.every(POLL_MS, () => void refresh($))
48 }
49 return done
50 })
51
52 // Any of these may have written a source (Edit, Write, a sed in Bash) or
53 // the stamp (lxx test): look again once it has run.
54 on('tool.call', async ($, e, next) => {
55 const ran = await next(e)
56 if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'Bash'
57 || e.tool === 'NotebookEdit') {
58 await refresh($)
59 }
60 return ran
61 }).catch(($, e, next) => next(e)) // a status line never blocks a tool
62}
63