file:line refs to blob permalinksgritzko 2026-08-14: minting rides the commit — a hook scans the changed files for NEW file:line(:col) refs and rewrites them into file:OFF:HASHLET permalinks (LITE-025 form, BLOB hashlet only). Beagle and git repos alike need one; lite deals only with git, so there it is a git hook planted by the install routine. Commit time is exactly when every staged blob's hash is known, while the commit id is not — which is why the LITE-025 hashlet is blob-only.
LITE-025 defines the permalink: ron64 byte offset into the named blob + the blob's hashlet (min 4, even, non-digit, unique in the path's own blob history, earliest match wins). The transient file.c:12:24 form is what humans and compilers write; committed text is where it rots — the hook upgrades it at the last moment it is still current.
A pre-commit hook + its installation, lite/git only; the beagle-native (post-verb) counterpart is separate ground, not this ticket.
path:line(:col) refs (all-digit anchors) whose path resolves in the repo; compute the target line's byte offset in the target blob; rewrite to path:OFF:HASHLET; re-stage..git/hooks/pre-commit, composing with a pre-existing hook rather than clobbering it.///lite worktree only; no VC ops; the hook itself is the only thing allowed to re-stage, and only files it rewrote.F anchors, not a regex re-scan (no manual URI parsing).file:line ref lands with the permalink form in the committed blob, and a follow through the LITE-025 resolver hits the right line.tag, so the F anchor and the addedness come from the
SAME walk — no unified-diff parsing, no regex.exit 0 would never reach an appended
line, and a linter later in the file then sees the rewritten text.hash-object -w +
update-index --cacheinfo, then the SAME upgrades over the working file ref
for ref, so an unstaged diff never shows a link-form difference. The
earlier "partially staged file is left alone" skip is RETIRED.FSW.c:9 is
a prefix of FSW.c:90, so the lexer decides what a ref is on that side too.
A ref that exists only on disk was not part of this commit and waits.diff-index base, the staged set alone resolves every path, and the
hashlet scope degenerates to that one staged blob. openRepo's HEAD gate
is right for the read verbs and stays; hook.js owns the HEAD-less handle.quickjab/build-release is jsrc-PACKED (bundle-only — it ignores a lite
worktree). Probes ran on ~/tmp/l25/qjb/bin/quickjab, the unpacked build.index.js openRepo refuses a repo with
no HEAD ("has no HEAD to index"), so no ref has anything to resolve against.
The hook says so and does NOT block the commit. Lifting it means touching
openRepo, which every verb shares — reported, not improvised around.Worker wt work/LITE-026, lite base ///lite/#846a3495 (carries LITE-025).
7 files jab put, NO commit. Full lite suite vs a pristine clone of the same
base, same runtime: failure counts IDENTICAL (blob 12/13, cat 16/35, list 17/78,
pager 1/33, tree 25/47 — all pre-existing), merge still 46/46 green with the
install change, plus the new hook suite.
index/hook.js NEW (319): the pass. stagedFiles asks git ONE question
(diff-index --cached --raw -z HEAD, captured over a scratch fd — the
runtime's io has no pipe) because lite reads no .git/index; the staged
BYTES then come off lite's own ODB, since git add already wrote the blob.
freshRefs walks the HEAD->staged fold and takes the alive F tokens the
HEAD side does not carry, splitting each through main.js's ONE splitRef.
targetOf answers from the commit's own new files first (no tree carries
them yet), then the LITE-011 descent at HEAD; two answers = untouched.
mintRef takes the target's STAGED image when this commit changes it,
HEAD's blob otherwise. Then the fixpoint, the write-back and git add.index/perma.js +82/−2: the MINT direction beside the follow, so one file
owns the ron64 packing both ways — packOffset, packPair/mintHashlet
(min 4, even, one non-digit, extended BY 2 against the path's own other
blobs), byteAt (the inverse of lineCol), blobHistory (the scope both
halves share) and walkNew (the shared was->now token walk).index/merge.js +4/−1: install also plants the hook (hook.js owns the
planting); the report line now names both halves. main.js +9: the hook
verb, reporting on the message stream only. README.mkd +2/−1.test/hook/{run.sh,hook.js} NEW (193+195): PASS 8 shell checks + 20 JS
checks, 0 bad. RED FIRST on the same fixture: 1 shell bad (install plants
nothing) and 7 of 20 JS bad (nothing minted). The leg is END TO END — a
throwaway repo, a PRE-EXISTING pre-commit that ends in exit 0, lite
install composing with it, then a REAL git commit; the assertions read
the COMMITTED blobs. Untouched legs asserted byte for byte: a path nothing
answers, a line past the end, a self-link, a path naming two files, and the
ref sitting in COMMITTED text. Then the follow back through LITE-025 —
the door and a real SGR click on a tty.openpty() slave.Spec/code notes for gritzko, nothing improvised around:
diff-index names it
and the object is already in the ODB. LITE-025's follow still reads a
staged-only change as a WORKTREE one (lite has no .git/index reader), so
the two halves disagree about what "staged" means until such a reader lands.test/perma/pty.js carries its own throwaway mint() (LITE-025 marked
minting out of scope there). It is a test oracle, deliberately independent
of index/perma.js, so it was left as written — test/hook/hook.js keeps
the same independence on purpose.Suggested commit (lite wt): LITE-026: the commit hook mints permalinks
Worker wt work/LITE-026b, lite base ///lite/#6ba244a3 (the landed hook).
4 files jab put, NO commit. test/hook PASS 11 shell + 20 + 6 JS checks, 0
bad. RED FIRST on the same fixture: the partial-staging commit did not upgrade
and the root commit minted nothing (2 bad). Full suite vs a pristine clone of
#6ba244a3, same runtime: failure counts IDENTICAL (blob 12/13, cat 16/35, list
17/78, pager 1/33, tree 25/47 — all pre-existing), merge 46/46 both ways.
index/hook.js +119/−39. WRITE-BACK, per the ruling: stageBytes stores
the rewritten bytes (hash-object -w --no-filters — they are already
index-side content, so no clean filter runs twice) and points the entry at
them with update-index --cacheinfo <mode>,<sha>,<path>; the mode comes off
the diff-index row that named the blob. git add is gone from the pass.
applySubs then upgrades the WORKING file by the same ref->permalink map,
matching whole F TOKENS (a substring hunt would eat FSW.c:9 out of
FSW.c:90); in lockstep with the index it degenerates to the rewritten
content. The partially-staged skip and its message are retired.
FIRST COMMIT: openUnborn is openRepo's handle minus its HEAD gate (via the
exported idx.discover/idx.reader), EMPTY_TREE is the diff-index
base so everything staged reads as added, targetOf already answered from
the staged set alone, and the hashlet scope is that one blob.index/merge.js +11/−1: install falls back to openUnborn — a fresh
git init is exactly when you install, and without it the hook could never
be planted BEFORE the first commit, so the ordered leg was unreachable.test/hook/first.js NEW (72): the root commit's own blobs — the permalink
is there, the self-link and the unresolvable ref are not touched, the
hashlet is the 4-char minimum, and the minted link follows back through the
LITE-025 resolver now that a history exists.test/hook/run.sh +61/−16: the fresh-repo fixture and commit; the
partial-staging leg REPLACED — it now asserts the committed blob carries the
permalink, the dirty working file carries the SAME one, the unstaged edit
survives, and git diff on that file shows ONLY that edit, no link-form
noise. Plus an explicit index-vs-disk lockstep check on the fully staged
file. The old "no HEAD explains itself" check is gone — that behaviour is
retired, and the plain-words fallback now only covers "not a repository".Suggested commit (lite wt): LITE-026: mint into the index, first commit too