English · Français
semctx reads the Git diff of a TypeScript repository and shows which exported contracts, declared rules and tests the change touches. It ends with one verdict, PASS, WARN or BLOCK. By default BLOCK exits with code 3, so a Git hook, a CI job or a coding agent can stop the change.
Analysis runs on your machine. It reads your files and your local Git history, and calls no LLM and no network service. To try it on your own code, start from a clean working tree in a repository with at least one commit (Bun 1.4.3 or newer):
bunx semctx@latest setup # once: config and local index
# ...edit some code...
bunx semctx@latest verify diff # verdict for your uncommitted editsThe quick start shows the branch check that CI runs.
The first-use demo uses a small repository with no tests.
Take it at its first commit, run semctx setup, then make the demo's three edits. One of them adds
a required field to an exported interface in src/cart.ts:
@@ -1,8 +1,9 @@
export interface CartTotal {
subtotalCents: number;
taxCents: number;
+ discountCents: number;
}
-export function computeCartTotal(subtotalCents: number, taxCents: number): CartTotal {
- return { subtotalCents, taxCents };
+export function computeCartTotal(subtotalCents: number, taxCents: number, discountCents: number): CartTotal {
+ return { subtotalCents, taxCents, discountCents };
}semctx verify diff prints this and exits 0:
Verdict: WARN
range : working tree
changed files : 3
impacted nodes: 7
Impacted contracts
Exported interface "CartTotal" is a public, compiler-enforced contract. [statically_verified]
Recommended tests
none
Unknowns
? Could not confirm index binding (STALE: ANALYSIS_INPUT_MISMATCH, WORKING_DIFF_MISMATCH); impact is reported without a freshness proof.
Findings
[WARN ] contract_changed_without_test: exported contract changed without a covering test: CartTotal
| In the report | What it tells you |
|---|---|
Verdict: WARN |
The answer. WARN is advisory, so the exit code is 0. |
changed files, impacted nodes |
Files in the diff, then the files and declarations whose lines it touches. |
Impacted contracts |
Exported interfaces and type aliases the change edits. |
[statically_verified] |
How semctx knows: read from the code's structure. tested means a test file imports it; inferred means it rests on a marker comment. |
Recommended tests |
Test files that import any symbol declared in a changed file, touched or not. The demo has none. |
? Could not confirm index binding |
The index was built before the edit. Expected when you check uncommitted work. |
[WARN ] contract_changed_without_test |
The rule that fired, and the symbol it fired on. |
computeCartTotal changed as well, but exported functions raise nothing by default: only exported
interfaces and type aliases count as contracts. The other two edits get no finding either. One
rewords a comment, the other slips an off-by-one into an unexported discount helper;
Limits explains why semctx misses that bug. A
public summary of the demo's evidence is published
online; it shows counts and verdicts, not this raw output.
flowchart LR
diff["Your Git diff<br/>working tree, --staged or --base"] --> map["What it touches<br/>symbols, exported contracts, linked test files"]
map --> rules["Rules<br/>built-in + @invariant, @tag critical, @tag security"]
rules --> verdict{"Verdict"}
verdict -- "exit 0" --> pass["PASS"]
verdict -- "exit 0" --> warn["WARN"]
verdict -- "exit 3" --> block["BLOCK"]
semctx index reads your code with the TypeScript compiler API and stores a graph of symbols,
imports, tests and markers in a local, git-ignored index. verify diff maps the changed lines onto
that graph, applies the rules, and the most severe finding sets the verdict. The exit codes above
are the defaults (--fail-on block).
You need Bun 1.4.3 or newer and a Git repository with at least one commit. This
flow checks a committed branch against main, which is also what the GitHub Action does.
-
Set up once, from a clean working tree (commit or stash first):
bunx semctx@latest setup
-
Make your change on a branch and commit it:
git switch -c try-semctx # ...edit code... git add -A && git commit -m "my change"
-
Index that commit, then check the branch against
main:bunx semctx@latest index bunx semctx@latest verify diff --base main
setup writes .semctx/config.json and six .sem templates in .semctx/semantic/ (comments only
until you write something), updates .gitignore and builds the local index. The config and the
templates are meant to be committed, which step 2 does. Re-running setup is harmless on a clean,
committed tree. It re-indexes, so never run it on uncommitted edits you are about to verify.
On a small repository setup ends with OK ready (analysis partial) and a WARN line on stderr.
That is normal; semctx index-health lists the reasons.
What setup printed on the demo repository (excerpt)
ok config written
ok semantic 6 file(s) scaffolded (.semctx/semantic/, versioned)
·· index analyzing 3 selected file(s) (typescript:3)…
ok index 9 nodes, 9 edges, 1 claims
ok check model consistent
!! analysis partial
TypeScript analysis: 1 worker(s), single
OK ready (analysis partial)
And on stderr:
WARN setup succeeded, but incomplete analysis cannot justify negative conclusions; run 'semctx index-health --json' for the exact gates and reasons.
Important
The index has to describe committed code. Run semctx index again after every commit, pull or
branch switch, otherwise verify diff answers BLOCK with index_binding_stale. For uncommitted
work, index a clean tree, edit, then verify without re-indexing in between: re-indexing an edited
tree can hide a finding.
--base needs a ref that exists locally, because semctx never fetches. Use your default branch name
if it is not main. To drop the bunx semctx@latest prefix, install the CLI once with
bun add -g semctx@latest. The rest of this README writes plain semctx.
The exit code depends on --fail-on:
| Verdict | default (--fail-on block) |
--fail-on warn |
--fail-on none |
|---|---|---|---|
| PASS | 0 | 0 | 0 |
| WARN | 0 | 3 | 0 |
| BLOCK | 3 | 3 | 0 |
A run that stops before a verdict, for example with no commit or an invalid option value, exits 1.
An unknown command or subcommand exits 2. --dry-run only resolves the Git range and the options:
it exits 0 when that works, fails like any other run otherwise, and does not load the config, so a
successful dry run says nothing about .semctx/config.json.
These are the rules setup writes to .semctx/config.json, plus one built-in check:
| Rule | Verdict | Fires when |
|---|---|---|
invariant_touched_without_test |
BLOCK | code under an @invariant changes with no covering test |
critical_contract_changed_without_test |
BLOCK | an exported interface or type alias tagged @tag critical or @tag security changes with no covering test |
security_surface_without_verification |
BLOCK | a symbol tagged @tag security changes with no covering test |
contract_changed_without_test |
WARN | any other exported interface or type alias changes with no covering test |
contradiction_unresolved |
WARN | the change touches a document marked contradicted or deprecated |
index_binding_stale |
BLOCK, always | the index was built for a different commit, as after a commit, pull or branch switch (run semctx index). With no index at all, the run stops before a verdict and exits 1 |
To relax a rule, set its severity to warn or remove it
(blocking rules). The GitHub
Action applies such a change only through config-path.
index_binding_stale cannot be configured. Neither can analysis_scope_incomplete, which exists
only with config v2: it blocks when a changed file in scope cannot be analysed, and warns when that
file's analysis is only partial.
semctx never runs your tests. In the default TypeScript-only flow, it checks direct imports under the symbol's declared name:
Import in a test file, with greet declared in src/index.ts |
Covers greet |
|---|---|
import { greet } from "../src/index"; |
yes |
import { greet as subject } from "../src/index"; |
no, aliased |
import * as m from "../src/index"; |
no, namespace import |
import { greet } from "../src/barrel"; where src/barrel.ts re-exports it |
no, re-export |
The opt-in config v2 qualified-static-v1 profile also follows canonical renamed imports,
modeled namespace members and re-exports to their original declarations. These are static test
associations; unknown or unsupported targets do not establish coverage. See the
qualified profile and its
admission contract.
Type-only imports do not count either. A file is a test when its name ends in .test.ts or
.spec.ts (or the .tsx, .mts, .cts variants), when it sits in a test/, tests/ or
__tests__ folder, or when it imports vitest, bun:test or node:test.
Without markers in your code, the symbol rules can only raise a WARN, and only for exported
interfaces and type aliases; a changed document marked contradicted or deprecated can also raise a
WARN. The built-in checks still block: index_binding_stale when the index
is out of date, and, with config v2, analysis_scope_incomplete. To make semctx block on your own
rules, write the rule in a one-line JSDoc tag on the code it protects,
as in examples/consumer-typescript-repo:
/**
* @invariant greeting-non-empty: a greeting must never be an empty string
*/
export function greet(name: string): string {
return `Hello, ${name}!`;
}Commit the marker and run semctx index: rules come from the index, and a working-tree check reads
the index built before your edit. Then change the body of greet while no test imports it:
- return `Hello, ${name}!`;
+ return name.length > 0 ? `Hello, ${name}!` : "";verify diff exits 3, and the report contains (excerpt):
Impacted invariants
! a greeting must never be an empty string [inferred]
Findings
[BLOCK] invariant_touched_without_test: invariant-constrained code changed without a covering test: greet
This edit really breaks the rule: it returns an empty string for an empty name. semctx cannot see
that. It sees that guarded code changed with no test behind it, and stops the change until a test
exists. To clear the finding, add one that imports greet from the file that declares it:
// test/greet.test.ts
import { expect, test } from "bun:test";
import { greet } from "../src/index";
test("a greeting is never empty", () => {
expect(greet("")).not.toBe("");
});Commit the code and the test, run semctx index, then semctx verify diff --base main. The finding
clears and test/greet.test.ts appears under Recommended tests. Run that test yourself: here it
fails, and that failure is what tells you the rule is broken.
Markers go directly above the declaration they guard:
| Declaration | Marker recognised |
|---|---|
function, or const/let holding an arrow function or function expression |
yes |
| class, interface, type alias, enum | yes |
| method, class property, plain constant | no, silently ignored; mark the class to guard its methods |
@tag security on a declaration the table recognises, or @tag critical on an exported interface or type alias, arms the two
other BLOCK rules. @tag critical on a function arms nothing. semctx only uses markers you write
and never adds any. A marker must fit on one line.
| Surface | Start with | What you get | Guide |
|---|---|---|---|
| CLI | semctx verify diff |
The verdict as text, GitHub annotations (--format github) or a versioned JSON report (--format json) |
CLI reference |
| GitHub Actions | uses: hoklims/semctx/packages/github-action@v0.4.5 |
An annotation at each symbol a finding names, a job summary and a verdict output; BLOCK fails the job |
GitHub Actions |
| Claude Code | bunx semctx@latest install |
The semctx MCP tools and skills, plus an optional guarded mode that holds git commit until the diff is verified |
Claude Code |
| Codex | bunx semctx@latest install --host codex |
The same MCP tools and skills | Codex |
| Any MCP client | tool semctx_verify_change |
The same diff check over MCP, next to the server's other typed tools | MCP setup |
Copy examples/github-actions/semctx.yml to
.github/workflows/, or let semctx setup --preset github-claude write it:
name: Semctx
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
jobs:
semctx:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # semctx needs the merge base and never fetches
- uses: hoklims/semctx/packages/github-action@v0.4.5
with:
base: ${{ github.event.pull_request.base.sha }}
head: ${{ github.sha }}
fail-on: blockThe action installs Bun itself, indexes the checked-out commit and verifies the pull request range.
WARN fails the job only with fail-on: warn. It needs no secrets and posts no PR comments, and it
never loads bunfig.toml or .env from the pull request, so a PR cannot run code on the runner
that way. Pin a release tag, because main and stable move.
The action starts from the default config, not the one committed in .semctx/. To apply your own
rules, keep a copy outside .semctx/ and pass it as config-path
(custom config).
What the annotations look like
The demo's WARN, printed with semctx verify diff --format github:
::warning title=semctx%3A contract_changed_without_test,file=src/cart.ts,line=1::exported contract changed without a covering test: CartTotal
::notice::semctx verdict WARN — 0 block, 1 warn (range working tree)
Run bunx semctx@latest install from the repository you want to check. It installs or updates the
semctx plugin for each detected host (Claude Code or Codex must already be installed), then runs
setup. --dry-run previews the plan, --host codex|claude|all picks the hosts, and
--skip-setup installs for the machine only. Re-running it is harmless on a clean, committed tree;
because it runs setup, which re-indexes, add --skip-setup when you have uncommitted edits.
The plugins give the agent the semctx MCP tools and skills, for example semctx_verify_change to
check its own diff before it says it is done. The plugin hooks run on Node, so Node must be on your
PATH too.
By default the plugins only report. On Claude Code, a .semctx/guard.json containing
{"enabled": true} turns on guarded mode:
git commit and git push are refused until semctx verify diff --record has recorded a non-BLOCK
verdict for exactly that content. With nothing recorded, the hook answers
semctx guarded mode: no verification on record. Set SEMCTX_GUARD=off to turn the mode off.
Codex has no guarded mode. When the Codex agent runs semctx from the shell, it calls a global
semctx, so install one with bun add -g semctx@latest.
Plugins update from the stable branch, which moves only when a release is published to npm.
semctx plugin-status shows the version you have.
Tested host versions
Semctx 0.4.5 requires Bun >=1.4.3.
The supported, tested host baseline is Codex 0.147.0 and
Claude Code 2.1.229. Other host versions are unknown until tested;
these pins do not claim the earliest historically compatible versions.
Baseline delivery evidence.
Installation does not reload an active session: open a new Codex task, or run
/reload-plugins in Claude Code (restart if reload fails).
Other MCP clients can run the server from a source checkout
(config snippet). The server registers
39 schema-declared tools (typed inputs and outputs), listed in
tool-contract.ts. Tools that read a repository take
its absolute path as repositoryRoot. SEMCTX_ROOT, if set, must be absolute.
semctx reads your code. It never builds it and never runs your tests, so treat a report as a list of what to re-check.
- A behaviour change inside a function gives no finding unless a marker covers it. In the demo, the
off-by-one in
src/pricing.tsis a real bug and gets no finding, because nothing marks that code. - An empty report does not prove that nothing was affected. When the index reports
analysis partial, as it does on the demo, semctx does not claim it found every reference to your code. Calls that cannot be resolved statically are left out, runtime behaviour such as races is out of reach, andUnknownslists only a few known gaps. - It sees what
git diffsees. Untracked files are ignored until yougit addthem (git add -Nis enough). Working-tree and--stagedchecks also skip symbols declared in a brand-new file: commit, index and use--baseto check those. verify diff --from-file <patch>always returns BLOCK withindex_binding_stale, because a patch file does not record which commit it came from. Verify changes in the repository itself.- Languages: TypeScript (
.ts,.tsx,.mts,.cts) by default; declaration files (.d.ts) are skipped, so a changed public declaration there raises nothing. Python through 3.12 is opt-in with config v2 (semctx setup --polyglot) and limited to modules, classes, functions, imports and markers, with no calls and no test links. Markdown and SQL files are only classified as documents and migrations. Files in other languages are skipped unless config v2 selects them: a selected file that no analyzer supports makesverify diffBLOCK withanalysis_scope_incompletewhen it changes. - Links from
.semfiles name a file and a symbol, so renaming either can mark them stale.semctx migrate anchors --applyrewrites older links that still carry line numbers. - It does not search code or pick the files an agent should read. Grep and embedding search do that better (ADR 0005).
semctx is pre-1.0 (0.4.x). The CLI, both plugins and the GitHub Action share one version and ship together from one release tag. Pin an exact version when you need reproducible results. Minor releases can still contain breaking changes; the changelog lists them.
| Status | What |
|---|---|
| Released | verify diff, impact diff, the GitHub Action, the Claude Code and Codex plugins, the MCP server, change records and the planning tools |
| Opt-in | config v2 (setup --polyglot) and guarded mode on Claude Code |
| Experimental | the Oh My Pi package, context prepare (semctx_prepare_task over MCP), and bench (ContextPack regression scoring); Grok can load the Claude Code plugin but is outside the tested host baseline |
| Partial | agent checkpoints, of which only before_completion is automated (reference, #28) |
| Withdrawn | the task-to-files retriever, which lost to plain BM25 search (ADR 0005, results); context prepare is what is left of it |
| Not built | anything that applies a plan for you |
| Not measured | accuracy: the public replay corpus has 30 real changes, none with a known expected result yet, so none is scored |
The private workspace package @semantic-context/experimental-context-pack-eval (in
packages/eval, formerly @semantic-context/eval) backs bench. It scores experimental
ContextPack regression expectations; it does not evaluate code-search effectiveness or
verify diff. Task frames remain available for task/plan reconciliation.
Planned work is in the roadmap.
CLI cheat sheet
semctx verify diff # uncommitted changes against HEAD
semctx verify diff --staged # what the next commit contains
semctx verify diff --base main # your branch, from its merge-base
semctx verify diff --fail-on warn # exit 3 on WARN as well
semctx verify diff --format json --output report.json # versioned JSON report
semctx verify diff --dry-run # resolved Git range and options, no analysis
semctx impact diff --base main # what the change can reach, no verdict
semctx index # rebuild the index for the current commit
semctx status # is the index still fresh?
semctx index-health # why the analysis is partialWrite options after the command: in semctx --json status, --json swallows status and the CLI
prints the general help. semctx --help lists every command with its main options, and the
CLI reference has the rest.
A pre-commit hook can run semctx verify diff --staged against an index built at the current clean
HEAD (example). Re-index after each commit, pull or checkout,
outside the pre-commit hook. On Claude Code with guarded mode, semctx verify hook pre-commit is the
project-managed alternative.
For a larger change, semctx change open change.<slug> --preserves <invariant-ids> records which
invariants the change must keep, and --requires <evidence-ids> the evidence it still owes.
semctx change verify change.<slug> later runs verify diff against that record. A record opened
without either flag checks nothing beyond verify diff
(walkthrough).
Configuration
.semctx/config.json holds the file selection and the rules. Commit it, together with
.semctx/semantic/, where you can write goals, invariants, decisions, assumptions, evidence and
unknowns in plain .sem files. The index itself stays local and git-ignored.
- Rules: each one maps a condition to a severity,
blockorwarn. To make WARN fail, use--fail-on warn(fail-on: warnin the GitHub Action) rather than editing the rules (blocking rules). - Version 1, the default, analyses every
.ts,.tsx,.mts,.cts,.md,.mdxand.sqlfile outsidenode_modules,.git,.semctx,dist,build,coverage,.turboand.next, except that TypeScript declaration files (.d.ts) yield no symbols. It does not apply itsincludeandtestGlobsfields, and it matchesexcludeas a plain substring. - Version 2 (
semctx setup --polyglot) appliesincludeandexcludeas globs and adds Python. An existing v1 config converts through a reviewed plan (config migration).
The configuration reference documents every field.
Planning tools, agent checkpoints and the lifecycle hook
You need this only to build or tune an agent workflow; the plugin skills already follow it.
The planning tools check that the index is fresh, follow code up to the intent written in
.semctx/semantic/, and compare a real diff with a plan. On the CLI they are semctx status and
semctx control ...; over MCP, semctx_control_status, semctx_control_trace,
semctx_control_plan and the other semctx_control_* tools. Most only read. Two write:
control target-propose saves a target architecture artifact under .semctx/semantic/targets/, and
control handoff stores a handoff capsule in git-ignored local state. One MCP tool reads no
repository and takes no repositoryRoot: semctx_control_verify_authorization, which checks a
sealed change-authorization capsule offline (design).
A READY plan grants no execution authority: these tools never edit code, commit, delete or deploy
(design).
Both plugins expose semctx_control_agent_lifecycle, a read-only checklist tool. Each call carries
a requiredAltitude, which says how high-level the work is, from L0 (diff hunks) to L6 (strategy).
The agent calls it at four points:
before_implementation_write, before its first write at L2 (component level) or above;after_repository_edits, after editing, with the ids of what it touched;before_completion, before saying the work is done;before_compaction, before compacting its context or handing the work over.
The answer is NO_OP (nothing required, as for pre-write work at L0 or L1), RECORDED (every
required step was reported) or INCOMPLETE (some are missing). It checks that the steps were
reported, not what they found.
semctx takes touched ids on the agent's word (caller_observed_advisory) and keeps nothing between
calls (stateless_caller_reinjected_unbound), so the agent resends earlier ids on every call.
Each plugin ships one lifecycle hook that automates only the before_completion checkpoint. It records which semctx MCP tools ran, as step ids in a git-ignored file, and reports on stderr at the end of a turn when that set changed.
It never blocks. From the host event it uses only the event name, session id, working directory and
tool name, and it never opens the transcript or reads source code. Set SEMCTX_LIFECYCLE=off to
turn it off.
The hook sees MCP calls only, so a step run through the shell CLI shows as missing. The other three checkpoints have no automatic host hook (details).
The documentation index lists every guide. Start with getting started or troubleshooting; the architecture overview and the design decisions explain how the pieces fit.
To run the demo from a checkout: bun install --frozen-lockfile, then
bun scripts/first-use-demo.ts --cli apps/cli/src/index.ts --out demo-evidence (a new directory),
then open demo-evidence/report.md.
Contributors start with the first contributor check and
CONTRIBUTING.md. Questions and bug reports go through SUPPORT.md,
vulnerabilities through SECURITY.md. semctx support and semctx feedback stay on
your machine and upload nothing (details).
semctx is licensed under Apache-2.0.