urtext
v0.5.2
Published
Diff review with evidence tiers: deterministic analysis, LLM interpretation, every claim labeled
Downloads
83
Maintainers
Readme
Urtext
A diff reviewer that shows its evidence. Point urtext at a git range and it reports what
changed and why it matters — ranked, and with every claim labeled by the kind of evidence behind
it. A verified finding can point at the line of code that proves it. Nothing is asserted on
the reader's trust alone, and the tool never issues an approve/reject verdict; the judgement
stays with the human.
The name is the thesis: in philology, the urtext is the authoritative original from which all editions derive. Here the code is the urtext; a review is an edition of it, and an edition that cannot cite its source is worthless.
What it does
urtext review takes a git range, analyses the change, and prints what
matters — ranked, with every claim labeled by the kind of evidence behind it:
verified— proven by static analysis; the report points at the codeinferred— a model claim that analysis corroborates but does not provemodel— a model claim nothing mechanical confirms
It analyses TypeScript projects, including the JavaScript in them. Guards, effects, and
citations read .ts, .tsx, .mts, .cts, .js, .mjs, .cjs and .jsx without
consulting the project's tsconfig at all. Surface and blast radius read the same TypeScript
extensions always, and the JavaScript ones only when the project's own compiler options
admit them — both need the type checker, not just a file they can parse on their own. Point
urtext at a Python, Go or Rust repository and the code analyzers find nothing; the citations
analyzer still checks prose in .md and .txt, and the dependencies and lockfile analyzers
still read package.json and package-lock.json — three of the seven. That is a limit, not
a roadmap item.
And every review says where that limit fell. A report names the changed files no
analyzer reported on — a package.json, a workflow YAML, a SQL migration — so silence
about a file is never mistaken for a clean bill of health. It claims non-reporting, not
non-reading, and it says plainly that anything the report does say about those files came
from the model alone. A report also names any changed file whose shape says a tool wrote
it — a bundle, a generated client — and says the same of it: no analyzer reported on it,
because code a build produced is not something a person wrote for a reviewer to read.
Seven analyzers run over the change:
- guards — conditionals, early returns, and throws removed from code that survived
- surface — exports added, removed, or changed shape
- blast radius — how many places reference a changed export
- effects — network, filesystem, process, env, database, and timing effects appearing or disappearing
- citations — prose that cites code by
path:lineor by a quoted phrase, where the citation resolved when its line was last written and no longer resolves now - dependencies —
package.jsonentries added, removed, or version-changed, in any of the four dependency maps; declared constraints only, since within a range the lockfile decides what actually resolves - lockfile —
package-lock.jsonagainstpackage.jsonand against its own previous state: a lockfile the manifest disagrees with, a resolved version that moved, a stale root version, and a count of how the transitive tree changed
Findings are ranked. A verified or inferred finding carries the evidence
behind it — file, line, and the quoted source. A model finding carries none
by construction, and the report says so where it prints one: it is a lead to
check, not a result.
Above the ranked list sits an index of what the change does not describe. When the model marks a finding as unaccounted for by the range's own commit messages, that mark is collected into one short block: "Not described by this change's messages (3)". It predicts nothing and reorders nothing below it — the ranked list stays untouched and complete. Fact-backed entries come first and model-only ones last, which is deliberately not the ranked list's order: that one is ordered for triage, where a claim alleging a problem sorts high; the index is ordered by what a reader can check, where evidence leads.
No entry in it can be verified, and that is structural rather than a policy. The mark is
something the model says, and a verified finding is one the model said nothing about, so
"marked and verified" is a contradiction. inferred is the index's strongest tier, and
when it carries a model-only entry it names the model that produced it.
A run that completes writes an HTML report into .urtext/ at the repository
root and prints its path; a run broken enough to exit non-zero deliberately
writes none, so a report on disk never stands in for a review that worked.
--export md,pdf additionally writes the review as GitHub-flavored Markdown
and as a client-presentable PDF beside the HTML report, sharing its name;
the same no-report rule applies to every format.
urtext does not edit the reviewed repository's .gitignore — it suggests
adding .urtext/ when nothing already ignores it, and leaves the file alone.
npm run review # working tree vs merge-base with the default branch
npm run review -- HEAD~3 # against a specific revision
npm run review -- --no-llm # deterministic analysis only; no API key needed
npm run review -- --json # machine-readable findings
npm run review -- --open # open the written report
npm run review -- --export md,pdf # also write Markdown and PDF beside the HTML report
npm run review -- --model claude-opus-5 # pick the interpretation model
npm run review -- --help # every flagThe interpretation stage needs ANTHROPIC_API_KEY. Without it, urtext reports
the analyzers' findings and says in the report that the model was never asked.
That includes the index of findings the commit messages do not account for: it collects marks the model makes, so a keyless run has none and shows no index. The report already says the model was not asked, so the absence is disclosed rather than silent.
Install
npm install -g urtext
urtext review # from any git repositoryThe published package ships its build, so nothing compiles on install and no lifecycle script runs — which matters under npm v12, where dependency scripts and git dependencies are both off by default.
Installing from GitHub needs those defaults relaxed. npm install -g
github:noahogbi/urtext is refused outright by npm v12 (EALLOWGIT), and even
allowed through it would fetch a tree with no dist/, whose build runs from a
prepare script that v12 also disables. Use the registry unless you have a
reason not to; from a local checkout, npm install -g path/to/urtext still
works.
The npm run review form above is the dev loop inside this repository; it runs
src/ directly and needs no build.
PDF export embeds the bundled DejaVu fonts, which cover Latin, Cyrillic, and Greek broadly but not CJK or other scripts — full-Unicode fonts cost tens of megabytes.
In CI
The CI integration does not come from npm. npm install urtext gives you the CLI;
the GitHub Action is action.yml at the root of
noahogbi/urtext, referenced by uses: rather than
installed as a dependency. Nothing is installed from the repository under review, and
no script of its own is executed. The runner itself needs node, npm, gh and jq,
all of which are preinstalled on GitHub-hosted runners; a self-hosted runner has to
provide them.
That action is a composite that reviews a pull request and posts the result as one comment, edited in place on every push:
name: urtext review
on: pull_request
jobs:
review:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # base...head needs the merge base, which a shallow clone lacks
- uses: noahogbi/urtext@v1@v1 is a moving major tag: it is re-pointed at the default branch with each
release, so it does change between runs, deliberately. That is the trade a major tag
makes — you get fixes without editing your workflow, and you accept that the code moves.
For a reference that cannot change, pin the commit SHA, which is what this repository
does for every action it consumes itself. Each move is made from a green cross-platform
CI run, the check you can see for yourself here.
Four of the action's behaviors were verified against live pull requests, and it is worth being exact about where that evidence sits: it was recorded during development, in a private repository this one does not descend from, so you cannot follow it from here. What was observed there — the workflow posted one marked comment carrying a real review; a re-run edited that same comment rather than adding a second; a deliberately failed artifact upload left the job green with the comment intact; and the same failure without the upload guard took the job red. Read those as the author's report, not as something this repository lets you check.
The pull_request_target refusal has been observed. It cannot be tested from a
branch of this repository — that trigger reads its workflow definition from the base
branch — so it was exercised in a throwaway repository carrying the misconfiguration on
its default branch. The action failed its guard step and every later step of the
composite was skipped: nothing fetched, nothing built, no attacker-authored code
parsed. Refusing before doing anything is the property that matters, and it is what the
run showed. That evidence was also in a private repository, so it is the author's report
on the same terms as the four above.
Worth correcting an earlier version of this paragraph, which said that refusal could not
be observed "from a branch" and was read — including by its author — as needing a fork.
It does not. pull_request_target fires for pull requests from branches in the same
repository too; the only requirement is that the workflow live on the default branch.
The fork case is verified, and it took two runs because no single run could show all of it.
A pull request from a fork
— a real one, from another organization — showed that GitHub does hand the workflow a
token that cannot comment: the post failed, the action warned in its own words
("the workflow's token may be read-only, which is the case on pull requests from
forks"), outcome came back reviewed, posted came back none, and the review
survived in the job summary and a 395-byte artifact. That run is red, and the reason
is this repository's own choice rather than the action's behavior: the workflow above
sets fail-on-error: true, which is the opposite of the default and is set only here,
because a urtext failure in urtext's own repository is a defect in the thing under test.
The green half is a second run,
on a workflow that simply omits pull-requests: write — the same 403 from the same
endpoint on the same path — with fail-on-error left at its default. It stayed green
with posted: none, which is what a consumer gets.
So: GitHub's read-only token is real, the handling is what this file says, and the default keeps the job green. Both runs are public and you can read them yourself, unlike the four behaviors above.
The permissions: block sits at the job level, not the workflow level, so adopting
this does not widen the token for a repository's other jobs. issues: write is not
required — a pull request comment is created through the issue-comments endpoint on a
pull request, which the pull-requests scope governs. The block itself is not
optional: without pull-requests: write the post comes back HTTP 403, which looks
exactly like the fork case below and is a different problem entirely.
By default the review is deterministic: with no key, the action passes --no-llm,
and the comment says so in urtext's own words. One line turns the model on:
with:
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}An undefined secret expands to the empty string, so that line degrades to deterministic on its own rather than failing.
The action never fails a pull request: a review that could not be produced is posted
as a comment saying so, not as a red check. Every step that can fail captures its own
status, and the one step that cannot — the artifact upload, which is a uses: step
with no shell to capture in — is marked continue-on-error. A failed upload therefore
costs the comment its full report link and empties the report-artifact-url output,
rather than reddening the check. Note what that costs on a large review: the job summary
is capped by GitHub at 1 MiB and the artifact is the uncapped copy, so past that size a
failed upload loses the only complete copy of the review.
fail-on-error: true opts into the opposite.
One exception remains, deliberately, and fail-on-error does not govern it either: the
pull_request_target refusal below fails the job on purpose.
Fork pull requests cannot be commented on. On a pull_request run whose head is
a fork, GITHUB_TOKEN is read-only regardless of the permissions: block and
repository secrets are unavailable, so the post fails with HTTP 403; the action emits
a warning, sets posted: none, leaves the full review in the job summary — and in the
uploaded artifact when one was uploaded — and stays green. No configuration changes this — a personal access
token does not help either, because secrets are not exposed to fork-PR runs. The
known-safe pattern is a two-workflow split: a pull_request workflow runs the review
and uploads the artifact this action already produces, and a workflow_run workflow —
which runs from the base branch with a write token and never checks out head code —
downloads it and posts. Shipping that second half is a deliberate follow-up.
pull_request_target is refused. The action fails its first step on that trigger,
on purpose: it grants a write token and secrets to a workflow that then reads the head
revision, and urtext parses attacker-authored TypeScript.
The job summary carries the full, untruncated review. GitHub caps a job summary at
1 MiB and a larger review is cut by GitHub, not by urtext; the uploaded artifact is
the uncapped copy. If a review comment exceeds comment-limit, whole findings are
removed and the comment says how many and where the rest are.
A failure comment does not shrink that way. Its headline, reason, closing sentence
and footer are fixed copy, so a comment-limit below their combined length produces a
comment longer than you asked for — and it is still posted. The action withholds a
comment only when the body exceeds what the API itself accepts, which lowering
comment-limit cannot cause; that run warns, sets posted: none, and leaves the review
in the job summary — and in the artifact when one was uploaded.
Every input is optional:
| Input | What it does |
|---|---|
| range | The git range to review, passed to urtext review verbatim. Empty derives <base sha>...<head sha> from the pull request payload. |
| anthropic-api-key | Key for the interpretation stage, passed to the CLI as an environment variable and never as an argument. Empty runs --no-llm; it never fails. |
| model | Passed as --model when non-empty. No effect without anthropic-api-key. |
| github-token | The token gh authenticates with. Needs pull-requests: write. |
| comment-marker | The hidden marker identifying this action's comment. Change it to keep two independent urtext comments on one pull request. |
| comment-limit | Maximum comment body length in characters — the forge's cap carried as data rather than compiled into urtext. |
| upload-report | Upload the run's .urtext/ directory and the Markdown review as a build artifact, and link it from the comment. |
| artifact-name | The name of that artifact. |
| fail-on-error | Exit non-zero when the review could not be produced or could not be posted. |
And the outputs a later step can read:
| Output | Value |
|---|---|
| outcome | reviewed when urtext produced a review, failed when it did not. |
| posted | created, edited, or none. none collapses three causes — a body the API would reject on length, a body that could not be composed, and a post the API refused — and the run's warning names which. |
| comment-id | The created or edited comment's id; empty when posted is none. |
| comment-url | The comment's html_url; empty when posted is none. |
| exit-code | urtext's own exit code, verbatim. The action interprets it; it never rewrites it. |
| omitted-findings | How many findings the character cap left out. |
| report-artifact-url | The uploaded artifact's URL; empty when upload-report is false, and also when the upload failed, which no longer fails the job. |
Every default lives in action.yml and is deliberately not restated here, so there is
one place to read it and one place to change it.
What leaves your machine
Nothing reaches the author of this tool. urtext has no server, no telemetry, and no account. The CLI runs on your machine; the action runs in your own runner, installing from its own checkout.
With --no-llm, nothing leaves at all. Every analyzer is local, every finding is
verified, and the review makes no network call.
With a key, what goes to Anthropic goes under your key and your agreement with them — never through anything the author operates. Traced against the prompt builder rather than described from the architecture, it is exactly this:
- the range label —
vs main, or verbatim whatever range you typed; - each changed file's path, its status, and the names of the symbols in it that changed;
- for each analyzer fact: its kind, its
file:line, its symbol, and one trimmed source line — the single line the finding points at; - the abbreviated hash, subject and body of each commit in the range, used to compare
the change against its stated intent, with
Co-authored-by:andSigned-off-by:trailers stripped first.
The diff itself is not sent, and neither are the contents of the files it touches. Only the one line per fact above. Git author names and email addresses are not sent either: the intent stage asks git for hash, subject, and body, and never for identity fields.
Nothing is sent when no key is present, and a run that did not ask the model always says
so — --no-llm was set or no ANTHROPIC_API_KEY set appears in the review's own notes.
The report is written to .urtext/ in your own repository. In CI it is also attached to
the workflow run as an artifact, which your repository's retention settings govern.
Something wrong with a finding? Errors, bad output, or a model claim that should
never have been printed: open an issue. A
model-tier finding that turned out to be nonsense is a useful report rather than a
nuisance — that tier exists because it is expected to be wrong sometimes, and reports
are how often gets measured.
Layout
src/extract/— git range → changeset (files, hunks, changed symbols)src/analyze/— analyzers producing typed facts with source evidencesrc/interpret/— the model stage: facts in, labeled claims outsrc/score/— importance weights, tier assignment, rankingsrc/report/— terminal and HTML renderers, and report writingsrc/cli.ts— entry pointaction.yml— the composite GitHub Action that reviews a pull requestaction/— the action's comment composer, plain ESM run by the runner'snodearchive/prototype/— the klar-era IR prototype, kept for provenance
Design: docs/superpowers/specs/2026-08-15-urtext-diff-review-design.md, and
docs/superpowers/README.md for how to read the rest of them — including which
citations in them no longer resolve here, and why they were left that way.
Provenance
Built ~March 13, 2026 as a standalone prototype under the working name
klar — an AI-native IR the model authored directly. Renamed urtext
and first committed to version control August 15, 2026. In August 2026 it was
re-aimed at the problem that had become the real bottleneck: reviewing
AI-written diffs rather than authoring code in an IR. The prototype lives in
archive/prototype/.
Contributing
Bug reports, and especially a review that stated something it could not support, are welcome: open an issue. For a vulnerability, use the private channel in SECURITY.md instead. CONTRIBUTING.md covers the process, what CI enforces, and the test policy.
