Your agent should know what your team knows.
Every team has a vocabulary. Billing means the totals are computed and never stored. The deploy means the one where the migration runs first. That flaky test means the one that fails on Tuesdays, for a reason three people know and nobody wrote down.
Claude Code does not have your vocabulary. So you re-explain it — every session, to every agent, forever.
The usual fix is .claude/rules/, or a CLAUDE.md that keeps growing. It works right up until it does not: every file loads at session start, every session, whether or not that session goes anywhere near the code it describes. So you keep the rules file small. So the vocabulary stays in people's heads.
claude-jit-context takes the ceiling off. Knowledge moves behind pattern matching, and arrives at the moment it applies: the migration note loads when someone opens a migration. The billing gotcha loads when the prompt says billing. A session that never touches PHP never pays for the PHP conventions.
Know more. Carry less. Everything else that saves context makes the agent dumber — trim the rules, drop the conventions, summarize the docs. This is the one lever that cuts what is resident without cutting what is known.
Four plugins, one team, each does one thing. This one and three siblings:
- claude-remember: memory across sessions. Saves, compresses through Haiku, reloads at the next start.
- claude-supertool: batched file and tracker ops. One call instead of seven, and a refusal instead of a wrong answer.
- claude-oss: the maintainer loop that runs these four repos. Triage, build, review, merge, release.
All four install from one marketplace: /plugin marketplace add Digital-Process-Tools/claude-marketplace.
The project this was extracted from runs 1,000 entries — 2.58 MB of markdown, on the order of 600,000 tokens. As .claude/rules/ that is not expensive, it is impossible: it does not fit in any context window that exists. A handful of entries fire in a given session. The rest cost nothing, and are still there the moment they are relevant.
That is the actual offer. Not a percentage off your context budget — a body of institutional knowledge with no ceiling, written down once, and read by every session and every developer who joins.
Nothing loads speculatively. Nothing is resident.
A friend told Florian that .claude/rules/ saves tokens — especially on something the size of DVSI, which is exactly the kind of project the feature seems written for. So he spent two hours moving our conventions into rule files, each scoped with a paths: glob, expecting sessions to get lighter.
Then he ran /context. Everything was loaded. Every rule file, in a session that had touched almost none of the code they described.
We spent a while assuming we had misconfigured it. We had not — as far as we could measure at the time, the scoping did not gate the load at all.
The interesting part came after. If Claude Code can inject context at the moment a file is opened, why should the trigger be a file? Why not the word XSD when someone types it? Why not the moment a test command is about to run without --no-coverage?
Those two questions are the vocabulary and tool dimensions. The whole plugin is the answer to them.
1. Zero until triggered. An entry costs nothing to own. That is the whole design: the price of writing something down stops being a reason not to write it down, so the corpus grows to the size of what your team actually knows rather than the size of what you can afford to keep loaded.
2. Three ways to be needed. Knowledge attaches to a keyword in the prompt, a file path being touched, or a tool about to run. Pick by asking when the reader needs it. Most notes belong to a folder, not to a word — the expensive mistakes happen while touching, not while talking.
3. It can say no. The tool dimension does not only inform, it blocks — refusing the call and returning the reason. A require: that fails the command is worth more than a paragraph that gets skimmed. A rule that is merely bold has already been ignored.
4. One awk, 30–110 ms. Frontmatter is compiled to a TSV index at build time, so the runtime is a flat file scan. No jq, no Python, no Node. This matters because it runs on every prompt and every tool call — the thing that fires constantly must cost nothing.
5. Hand-written and generated, side by side. 00-manual/ is yours. The other layers belong to generators, which can maintain bulk coverage across a large codebase without ever overwriting a line a human wrote.
You will feel it before you can name it: the agent is rediscovering something it should already know.
It greps for a file it found last week. It re-derives a convention you have explained four times. It proposes the fix that was already tried and reverted, for a reason nobody wrote down. Every one of those is the same event — knowledge that exists on your team, and does not exist where the agent can reach it.
That moment is the trigger. Do not re-explain it in the chat, where it dies at the end of the session. Write the entry:
---
title: Billing amounts
description: How invoice totals are computed, and why the entity getter lies.
keywords: billing, invoice, amount, vat, total
---
Totals are **not** stored. `getAmountVatOut()` is recomputed from line items on
every call. Writing to `amount_vat_out` directly appears to work and is silently
discarded on the next read.bash scripts/rebuild-tsv.shThe search you just paid for is the reason the entry is worth writing — and the only moment you will ever know it well enough to write it in four lines. From then on it arrives on its own, in every session, for everyone, and nobody spends that search again.
A codebase accumulates this way faster than anyone expects. That is why pillar one matters: if entries cost context to own, you ration them, and the rationing is what keeps your team's knowledge trapped in people's heads.
/plugin marketplace add Digital-Process-Tools/claude-marketplace
/plugin install claude-jit-context@dpt-plugins
To update later:
/plugin marketplace update
Restart Claude Code after installing. Hook registrations are read at session start, so a plugin enabled mid-session has no hooks wired for the rest of it.
The same hooks, from a separate catalogue. Claude Code reads .claude-plugin/marketplace.json and Codex reads .agents/plugins/marketplace.json, so the two marketplaces are two repositories — but the plugin id is the same on both.
codex plugin marketplace add Digital-Process-Tools/codex-marketplace
codex plugin add claude-jit-context@dpt-plugins
Then trust the hooks, or none of this runs. Codex skips an untrusted hook the way it skips a hook that is not there: no warning, no non-zero exit, no transcript line, the tool call simply proceeds. So the symptom is that nothing happens, and nothing happening is also what a rule that does not match looks like. Count them before you debug a rule — six is the number for this plugin:
grep -c trusted_hash ~/.codex/config.toml
Driven on codex-cli 0.150.1, from an empty CODEX_HOME: the two commands above install it, a prompt keyword injects the entry body, a path rule fires on the file being touched, and a mode: block tools rule refuses the call. The payload carries session_id, so each entry still fires once per session rather than once per prompt.
Copy this directory to <your-project>/.claude/claude-jit-context/ and register the hooks in .claude/settings.json:
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/claude-jit-context/scripts/session-start-hook.sh"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/claude-jit-context/scripts/pre-prompt-hook.sh"
}
]
}
],
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/claude-jit-context/scripts/pre-tool-hook.sh"
}
]
},
{
"matcher": "Read|Edit|Write|Glob|Grep|Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/claude-jit-context/scripts/pre-path-hook.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit|Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/claude-jit-context/scripts/post-tool-hook.sh"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/claude-jit-context/scripts/stop-hook.sh"
}
]
}
]
}
}Bash, awk, perl (used only for millisecond timestamps) and mktemp (one scratch file per hook fire, created with an unpredictable name so nothing outside your project can be pointed at). No jq, no Python, no Node.
mktemp is the only soft one: a system without it, or with no writable $TMPDIR, loses the hook log and the once-per-session dedup and keeps everything else — rules still match, entries still inject, the hook still exits 0.
Linux, macOS and Windows. The suite runs on all three in CI — including macOS's bash 3.2 and Windows under Git Bash. On Windows the hooks need a bash on PATH, which Git Bash provides; that is the same requirement every hook-based plugin in this family has.
This plugin runs under Claude Code and Codex, and both are hosts it has been watched
run under. scripts/host.sh holds a small registry -- one row per host, the variables
it uses, and whether this plugin has observed it fire -- rather than a Codex-shaped
branch bolted onto each hook.
Two things live in that registry per host: which environment variables identify it, and
its output envelope contract -- what an injected note looks like, what a refused
tool call looks like, and whether refusing is even possible there. That second part is
the sharper question here than for a plugin that only injects: a forbid: rule silently
degrading to advisory the moment a host's refusal contract is assumed rather than
watched is this plugin's worst failure mode, so an unobserved host reads as exactly
that -- refusal-not-established -- never as block or advisory.
Codex was UNKNOWN for a while on purpose. The
remember plugin had
watched Codex fire its own inject-only hooks, and that was never evidence about this
plugin's harder contract -- a row here is never set to OBSERVED on the strength of
another plugin's observation. #288 supplied the missing half, on codex-cli 0.150.1:
PreToolUse fired, the payload carried tool_name and tool_input under those names,
and a {"decision":"block"} stopped a real command from running. Gemini CLI has a row
and is still UNKNOWN, for exactly the reason Codex no longer is -- nobody has watched
a block fire under it.
That last sentence was measured on a Bash call, and a live Codex run (#364) found the
same envelope does not mean the same vocabulary: Codex names a file edit apply_patch,
never Edit or Write, so a rule you write as tool: Edit|Write matched nothing on
Codex until this was fixed -- no error, no warning, the call simply went through. You
never need to write apply_patch in an entry to fix this: scripts/host.sh carries a
tool_aliases column that maps a host's own tool name onto the canonical vocabulary
(Claude Code's own names) before any rule is matched, so tool: Edit|Write still means
exactly that on every host, including one that spells it differently underneath.
A fresh install has nothing to match, so nothing happens — and the first thing anyone wants to know is how to make something happen. Ask the plugin:
/jit-context:initThe recommended install is the marketplace, and after that install this is the only path
that exists: $CLAUDE_PLUGIN_ROOT is not a variable your own shell has, and the
alternative — finding jit-init.sh under ~/.claude/plugins/cache/... — changes on every
update (#202). The raw script still works too: bash scripts/jit-init.sh from a clone, or
bash .claude/claude-jit-context/scripts/jit-init.sh after the manual install above.
It creates vocabulary/, paths/ and tools/ under .claude/jit-context/, drops one
entry that answers "how do I write one of these?", and builds the index so that entry is
live rather than sitting there inert. Type "how do I write a jit entry" in your next prompt
and it arrives — the documentation delivered by the mechanism it documents.
The file is yours from that moment. Edit it, delete it, or write your own beside it. A
second run refuses rather than overwrites: a copy you have edited is not ours to
replace, and that refusal exits 1 and says which file it left alone — /jit-context:init
relays that refusal verbatim.
Nothing else is installed and no rule is read from outside your project. Everything the hooks ever match lives in your repository, where you can read it.
It seeds the project you are standing in; --base DIR seeds another —
/jit-context:init --base ~/work/other-project/.claude/jit-context — and refuses any path
that is not a <project>/.claude/jit-context, because seeding anywhere else writes entries
no hook will ever load.
--base is resolved before anything is written, so every path it prints is the physical
location of the files — a symbolic link above .claude is followed and reported at its
target, and .. is folded away. A link at or below .claude is refused rather than
followed: the hooks will not read an entry through one, so seeding past it would leave a
rule that can never fire.
examples/jit-context/ carries five entries — one paths/, one vocabulary/ and three
tools/, one each for remind, require and forbid — in the exact layout the hooks
read. Copy the tree into your project and rebuild:
cp -R examples/jit-context/. .claude/jit-context/
bash scripts/rebuild-tsv.sh
bash scripts/jit-dry-run.sh --base "$PWD/.claude/jit-context" --command "git push origin main"They are samples with real frontmatter, so treat them as a starting point and not as
rules about your project — but every one of them is driven in both directions by
tests/test-shipped-examples.sh, on the same hooks your session runs. The --base there
is absolute on purpose: a relative one is resolved against the dry-run's own working
directory, which prints SKIPPED for every sample and still exits 0.
Knowledge attaches to one of three triggers. Pick by asking when the reader needs it.
| Dimension | Fires on | Hook | Lives in |
|---|---|---|---|
| Vocabulary | keywords in the prompt | pre-prompt-hook.sh |
.claude/jit-context/vocabulary/ |
| Paths | a file path being touched | pre-path-hook.sh |
.claude/jit-context/paths/ |
| Tools | a tool name + command pattern | pre-tool-hook.sh |
.claude/jit-context/tools/ |
Vocabulary answers "what are we talking about?" — the billing module, the deploy process, that one flaky test.
Paths answer "what shape must this file have?" — the conventions for anything under Commands/.
Tools answer "what must happen around this action?" — required flags on a test command, a checklist before git push.
The tool dimension is the only one that can block. The other two only inject.
A match injects the entry body, whole. That is the default and it is what this has always done.
It is also the thing worth thinking about, because the cost is asymmetric: a miss costs nothing, and a false positive costs the whole entry. One session about token tooling pulled a 14.9 KB tag-hierarchy reference on the word tag, in a conversation about YAML metadata. The match was correct on its own terms — the word was there — and it was 15,000 tokens wrong.
summary mode is the answer to that. A match then injects the entry's title and its description: — roughly 20 tokens — and the agent reads the file if it wants the rest. Being wrong gets cheap, rather than the matcher getting cleverer:
# Vocabulary: tag-system-gotchas.md (matched: tag)
Tag hierarchy
How tags nest, and why tag_relation rows are written in pairs.
[jit] Summary only -- read .claude/jit-context/vocabulary/00-manual/tag-system-gotchas.md for the entry.
# .claude/jit-context/config.env
JIT_CONTEXT_INJECT=summaryIt is not the default, and the reason is upgrade safety rather than doubt about the trade. A project that installed this before the mode existed has entries that arrive whole and agents that behave as though they will. Flipping that under them, on an upgrade nobody read the notes for, takes away knowledge the project already relies on and does it silently — which is the exact failure this plugin exists to name, committed by the plugin. So full is what you get if you say nothing, and summary is where you go once you have looked at what a match costs on your tree and decided.
That last part is meant to be a decision and not a slogan, so the numbers are printed for you. rebuild-tsv.sh prices one match on your own corpus:
=== What a match costs on this tree ===
Project default: JIT_CONTEXT_INJECT=full
Every match injects the whole entry. Per match, on this tree:
largest 6105 bytes -> 311 summarised .claude/jit-context/paths/00-manual/hooks.md
median 3718 bytes -> 310 summarised .claude/jit-context/paths/00-manual/tests.md
7 entr(ies) indexed.
Every entry carries a description:, so this tree can move to summary whenever
you decide the trade is worth it: JIT_CONTEXT_INJECT=summary in config.env.
It prices one match, never a corpus total. Nothing here is ever resident, so "summary mode would save 2.4 MB" would be a true sentence about a quantity that has never been in a context window. How often each entry actually fires is in hooks.log, which is the only place that number exists.
An entry with no description: cannot be summarised into anything but its own name, so those are named as the work between you and being able to flip. jit-dry-run.sh carries the other half — what a specific call cost, measured rather than estimated:
pre-path-hook.sh hooks.md(WHOLE BODY) [6245 bytes injected]
pre-path-hook.sh hooks.md(summary) [385 bytes injected]
The project chooses, not the entry's author. The default lives in config.env, set by whoever pays for the context window; an individual entry overrides it with inject: summary or inject: full. That asymmetry is deliberate — an author who marks their own entry critical is marking it against a count somebody reads at build time.
Four rules that are not settings:
- An entry with no
description:is named and not injected, undersummary. Nothing is auto-derived — a generated summary of a wrong entry is a confident wrong summary, and it removes the moment you would have noticed. What you get instead is the entry's name and a line saying it has no description. - A tools rule that refuses a call injects its whole body, whatever the mode says. The call is already stopped; there is no next turn in which to spend a cheaper answer.
- An entry with no frontmatter at all injects its body, in every mode. It has no
description:— and nokeywords:ormatch:either, sorebuild-tsv.shcould not have indexed it. Its body is the entry. - The
(matched: …)header is inside the budget, not beside it. Wherever a tools match injects something other than an entry body — a summary, the substitute for an entry file that could not be read, or an entry whose file holds nothing but blank lines — both index columns that line quotes are clipped and the cut is marked[clipped]: the pattern at 160 bytes, the file name at 255 (#146, #165). A 60 KBmatch:pattern cannot reopen the costsummarywas added to close, and cannot ride in on a row whose file is missing, or on one with nothing under its header to justify quoting it whole. The header is echoed whole in exactly two places, and both deliver the whole entry beneath it: afullentry that actually had a body, and a refusal.
Any other value — including gated, a third mode that is designed and deliberately not built, held until there is data on how often the pull step is actually taken — falls back to the default, which stands, and is named rather than silently ignored. The two settings say so through different channels, because they are different mistakes:
- A bad
JIT_CONTEXT_INJECTinconfig.envis a standing fact about the project, so it is refused and named inhooks.logand once per session in context. - A bad
inject:in one entry is a property of that entry, so it is named inside what that entry injects, every time it fires and in either mode. It costs 94 bytes, it rides an injection that is already deduped per session, and it stops the moment you fix the line. Underfullthis said nothing at all until #118 — a mistypedinject:produced an entry that behaved exactly as though the line were never written, on the path every unconfigured project is on.
The loss summary buys with is real and worth stating: the pull is a soft rule, and an agent under momentum will sometimes skip an entry it needed. Whether that happens is measurable — reading an entry is a tool call, so it lands in hooks.log beside everything else.
Every entry is a markdown file with YAML frontmatter, in 00-manual/. The frontmatter is the only structured part; the body is free-form and goes into context verbatim, unless the project has opted in to summary mode.
Two fields apply to every dimension:
| Field | Required | Meaning |
|---|---|---|
title |
no | One line. Injected on a match under summary. |
description |
write one | One line saying what the entry holds. It is what a match injects under summary, and what the agent decides on. Without it, a match can only name the entry — and rebuild-tsv.sh lists the entries that have none. |
inject |
no | summary or full. Overrides the project default for this entry alone. |
---
title: Billing amounts
description: How invoice totals are computed, and why the entity getter lies.
keywords: billing, invoice, amount, vat, total
---
Totals are **not** stored. `getAmountVatOut()` is overridden by `BillingTotalsTrait`
and recomputed from line items on every call (`src/Billing/Totals.php:88`).
Writing to `amount_vat_out` directly appears to work and is silently discarded on
the next read.That is a vocabulary entry, matched on a keyword in the prompt. A paths/ entry matches a file path instead, and a tools/ entry matches — and can refuse — a command. Every field, every mode, invocation anchoring and the awk-vs-PCRE pattern rules are in docs/writing-entries.md and docs/patterns.md.
Entries do nothing until they are indexed:
bash scripts/rebuild-tsv.shThe hooks read 00-index.tsv, never your markdown, so an edited entry that has not been rebuilt is inert — nothing errors, nothing warns, the rule simply never fires. Run it after every frontmatter edit; a body-only edit does not need it, since the body is read from the file at fire time. docs/diagnostics.md covers the ambiguity report, the exit codes, and the tools — jit-doctor.sh, jit-match.sh, jit-dry-run.sh, jit-misses.sh — that answer "is any of this actually running".
Everything past the getting-started arc above moved to docs/ rather than being deleted:
docs/writing-entries.md— every field, every mode,requires:, which tools a rule can name, anchoring on an invocationdocs/patterns.md— awk vs PCRE, and the tools-that-touch-files-through-Bashcompatibility layerdocs/keyword-matching.md— normalization, accent folding, the generic-keyword fallback, once-per-session dedupdocs/layers.md—00-manual/,10-auto/, and writing a layer of your owndocs/configuration.md—config.envdocs/performance.md— hook timing,hooks.log, the log capdocs/diagnostics.md—jit-doctor.sh,jit-match.sh,jit-dry-run.sh,jit-misses.sh, and the ambiguity report
bash tests/run-all.shOne suite per hook, plus the dry-run, the miss report, and what a hostile project directory can make the hooks read, write or say — matching, normalization, modes, blocking, session-once behaviour and malformed input. Engine-sensitive assertions run once per awk on the machine. No dependencies beyond bash, awk and perl.
Two suites are the exception, and they are about this repository rather than the hooks. One is release tooling: test-changelog-fragment-refs.sh checks that nothing outside changelog.d/ names a fragment the next tag will delete. The other is prose: test-line-citations.sh refuses a comment — or, since #198, a line of markdown anywhere in the repository — that cites a line number in another file, because such a pointer is wrong on the next change that inserts a line above it and a rotted citation reads exactly like a live one. Both are file reads — no python3, no markdown-it-py — so run-all.sh still needs nothing but bash, awk and perl. The assembler that first suite is about is .oss/assemble_changelog.py, which is vendored rather than maintained here and has its own suite upstream; the oss changelog workflow installs the parser and drives it on every pull request.
The assertion count is deliberately not written here. It was wrong three times in one hour on the day this sentence was rewritten: every branch that adds a test invalidates it, and nothing fails when it drifts — the same defect tests/test-version-sites.sh exists to catch for the version number. Run the suite; it prints the number it actually has.
run-all.sh also times each suite and prints the times after the pass/fail tally, slowest first, plus a total — wall-clock seconds via bash's own $SECONDS, never date +%s.%N, which is a GNU extension BSD date on macOS does not support. This never changes the exit code and never fails a slow suite; it exists so the three CI legs are directly comparable in the log, since nobody here can measure Windows locally. If timing genuinely cannot be taken on some run, a suite line reads n/a rather than 0s -- a suite that took no measurable time and one nobody could time must not render the same way.
The two containment suites build their fixtures with ln -s, and a platform that does not create symbolic links cannot construct the attack they exist to refuse — Git Bash copies the target instead unless MSYS=winsymlinks:nativestrict is set and the process may create links. Those suites probe for that first and skip loudly rather than pass, and run-all.sh reports a skipped suite as neither a pass nor a failure. A suite that reported success where it could not test anything would be the exact defect this plugin exists to describe.
CI sets MSYS=winsymlinks:nativestrict — meaningful only to Git Bash, so only the Windows leg changes — and declares the requirement with JIT_TESTS_REQUIRE_SYMLINKS=1 on all three legs. That second variable separates two things a bare skip cannot tell apart: a platform that never had symbolic links, which skips, and an environment configured to have them that did not get them, which fails — a skip renders green, so a setting that quietly stopped applying would restore the hole without anyone noticing. Set it yourself if you want the same guarantee locally; leave it unset and the honest skip is what you get.
Files in .claude/rules/ auto-load at session start — all of them, every session. When we measured this on a large codebase in early 2026, globs frontmatter did not change that: /context showed every rule file resident, whatever its scope. Claude Code moves quickly, so check it yourself with /context before taking our word for it — but that measurement is why this plugin exists.
On a project with a real body of institutional knowledge, that is the difference between a context window mostly full of maybe-relevant documentation and one mostly full of the actual conversation.
See CHANGELOG.md. It is assembled at release from one fragment per change —
contributors add changelog.d/<issue>.<section>.md and never edit the file itself. The
convention is in changelog.d/README.md.
That assembler is Python and lives under .github/, which is not the runtime this page
promises: nothing in .github/ ships inside the plugin, and the six hooks that run in your
session are still bash + awk + perl with no install step.
Source-available under the Community License. See LICENSE. Use permitted. Modification, redistribution and resale prohibited.
Third-party licence terms for data bundled in this repository (data/generic-words.txt)
are reproduced separately in NOTICE.
