lockfile-sync
A Claude Code Mod that tells the model when a commit changes the dependencies of a manifest but not its lockfile. After each git commit the model runs, the mod adds the manifests whose lockfile the commit left out to the commit's result. The commit is never stopped.
What it does
The mod hooks the Bash tool. A command that runs
git commit(alsogit -C <dir> commit, not--dry-runor--help) is checked.Before the command runs, it finds the repository root from the session's directory, the last
cdbefore the commit and the commit'sgit -C, and recordsHEAD.After a successful command that moved
HEAD, it lists the commit's added and modified files withgit show --name-status HEAD, and pairs each manifest with its lockfile:Manifest Lockfile package.jsonpackage-lock.json,yarn.lock,pnpm-lock.yaml,bun.lock,bun.lockbcomposer.jsoncomposer.lockCargo.tomlCargo.lockgo.modgo.sumpyproject.tomlpoetry.lock,uv.lock,pdm.lockPipfilePipfile.lockGemfileGemfile.lockpubspec.yamlpubspec.lockmix.exsmix.lockThe lockfile is the first one on disk from the manifest's directory up to the repository root, so a workspace package pairs with the root lockfile. A manifest without a lockfile on disk is left alone: the project does not keep one.
When the commit leaves that lockfile out, the mod first asks the lockfile's own package manager whether the lockfile still fits the manifest. It runs the check by argv in the lockfile's directory, with a 60 s limit:
Lockfile Check Behind when Cargo.lockcargo metadata --locked --format-version 1 --manifest-path <manifest>cannot update the lock filepackage-lock.jsonnpm ci --dry-run --ignore-scriptsare in syncin the failurepnpm-lock.yamlpnpm install --frozen-lockfile --lockfile-only --ignore-pnpmfile --ignore-scriptsdon't match specifiersbun.lock,bun.lockbbun install --frozen-lockfile --dry-run --ignore-scriptslockfile had changesyarn.lock(v1)yarn checkLockfile does not contain patterncomposer.lockcomposer validate --no-check-all --no-check-publish --check-lock --no-pluginslock file is not up to datego.sumgo mod tidy -diffthe diff holds a go.sumhunkuv.lockuv lock --checkneeds to be updatedpoetry.lockpoetry check --lockchanged significantlypdm.lockpdm lock --checksatisfy the project requirementsPipfile.lockpipenv verifyout-of-dateGemfile.lockbundle lock --printthe printed lockfile differs, platforms and the Bundler version aside pubspec.lockdart pub get --enforce-lockfile --dry-runUnable to satisfymix.lockmix deps.get --check-locked, withMIX_DEPS_PATHin$TMPDIR/lockfile-syncmix.lock is out of dateEach check was measured to write nothing into the repository. A pass means the lockfile fits and no finding opens: a
featureschange inCargo.tomlthat pulls in no new crate opens nothing, and afeatures = ["derive"]that pulls inserde_derivedoes. A failure that says the lockfile is behind opens the finding. Any other answer proves nothing: the tool is not installed, it ran past the limit, it failed for another reason, the lockfile is a Yarn 2+yarn.lock, or the manifest or the lockfile differs fromHEADin the working tree. The check reads the working tree and the finding speaks of the commit, so a lockfile written but left out of the commit would read as in step. A tool that did not start is logged once:lockfile-sync: cargo did not run: <reason>; the manifest's diff decidesThen the mod reads the manifest's diff (
git show --unified=20 HEAD -- <manifest>) and checks where the changed lines sit. Only a change that can change the lockfile counts:Manifest Counts Does not count package.json,composer.jsondependencies,devDependencies,peerDependencies,optionalDependencies,overrides,resolutions,require,require-devand the likescripts,version, other keysCargo.toml,pyproject.toml,Pipfile[dependencies],[dev-dependencies],[target.*.dependencies],[project],[tool.poetry.dependencies],[packages]and the like[package],[tool.ruff], other tablesgo.modrequire,replace,excludelines and blocksgo 1.22,moduleGemfilegem,source,gemspec,grouplinescomments pubspec.yamldependencies,dev_dependencies,dependency_overridesother keys mix.exsevery change The section of a changed line is read from the whole manifest (
git show HEAD:<manifest>), not from the diff's own 20 lines of context: a change 40 lines into apackage.jsonnever reaches the root{inside the hunk, and every root-level key would read as a dependency. A key or table the manifest itself does not place counts, so a file that cannot be read still gets the note.The model reads this note after the commit's result:
lockfile-sync: this commit changes package.json but not package-lock.json · go.mod but not go.sum. Run the package manager's install so the lockfile matches, and commit it.The same moment writes one line to the transcript, so you see what the model was told. The line holds the pairs alone, without the instruction:
lockfile-sync: this commit changes package.json but not package-lock.json · go.mod but not go.sumThe note and the line are separate channels: the model never reads the line, and you never read the note.
While the sidebar is open, those pairs go there instead, one line per pair, as an entry in its stream, and the transcript stays clean. The entry stays until newer ones push it off the pane. With the sidebar closed, or without that mod installed, the transcript line is written as above.
Each commit that leaves a lockfile out opens its own finding, with its own sidebar entry keyed by its manifests. A later commit adds its finding beside the open ones and never writes over one; a pair an open finding already names is not opened twice. Every finding closes on its own measure.
A finding is never a remembered answer. Each measure, after every later commit and before a guarded git command, asks git and the package manager again, so it closes three ways:
- the lockfile was written: a later commit changed it, or
git status --porcelainshows it changed in the working tree; - the package manager reads the lockfile as in step with the manifest (the check of step 4);
- the check proves nothing and the manifest asks for no lockfile change any more:
git log -1 -- <lockfile>names the commit that last wrote the lockfile, and the manifest's diff against that commit touches no dependency. A change that was reverted reads this way. A lockfile the package manager reads as behind stays open whatever the diff says.
The entry is cleared and a new one says which of the three it was:
lockfile-sync: a later change brought the lockfiles along: package-lock.json lockfile-sync: cargo reads Cargo.lock as in step with Cargo.toml lockfile-sync: the dependencies match the lockfile again: package.jsonWith the sidebar closed the same text is one transcript line. The model reads nothing of this: the finding closed by its own work, so a note would only repeat what it just did.
- the lockfile was written: a later commit changed it, or
A finding the model did not close is measured again at the end of each main-loop turn, and what is left reaches the model as one note with its next prompt:
lockfile-sync: 1 lockfile(s) are still behind their manifest: package-lock.json behind package.json. Run the package manager's install so the lockfile is written, or take the dependency change back.One note per turn, not one per prompt. Without this the finding would be said once, at the commit, and then stand in the pane while the model forgot it. You read nothing new: the pane already carries the same finding.
In
denymode the mod also stopsgit commit,git pushandgit mergewhile a lockfile is behind. Before it stops one it runs both measures, so a lockfile the package manager just wrote, and a dependency change that was taken back, each open the gate themselves. Agit commitanswers for its own files alone: the mod reads the index (git diff --cached --name-only) and lets the commit run when it holds none of the open manifests, with one line to you naming how many still stand. Apushand amergehold no index to read, so every pair stands there. There is no bypass; only the person turns the gate off with/lockfile-sync mode note.notemode is the default and stops nothing.
A git error is logged once, and the commit's result stays as it was.
In the live check the model raised a package.json dependency, committed only that file, and quoted the note word for word.
Command
/lockfile-sync on or off, the mode, and the lockfiles still behind
/lockfile-sync on | off on by default
/lockfile-sync mode note note only; the default
/lockfile-sync mode deny a commit, a push and a merge also stop while a lockfile is behind
Install
claude plugin marketplace add KilimcininKorOglu/claude-code-mods
claude plugin install lockfile-sync@kilimcininkoroglu-mods
Function hooks are early access. Nothing loads without the flag. To keep it on, add this to ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
After installing
- Restart Claude Code.
What it can reach
Validated with claude plugin validate on Claude Code 2.1.282:
❯ ./register.ts hooks: session.start, command.run{command=lockfile-sync}, turn.complete, prompt.submit, tool.call{tool=Bash}
❯ ./register.ts calls: $.command.register, $.env.get (via tmpDir), $.fs.exists (via lockOnDisk), $.fs.read (via treeText), $.process.run (via git, lockVerdict), $.session.cwd (via beforeCommit), $.sidebar.clear (via dropEntry), $.sidebar.set (via toPerson), $.store.get, $.store.set (via runCommand, setMode), $.ui.log (via denyFor, report, toPerson, toolFailed)
Reach L3, runs processes that reach the network.
1. Reads: the Bash command text; whether lockfiles exist in the repository; each open finding's manifest and lockfile in the working tree; through git, the commit's file list, manifest diffs and each manifest at HEAD; TMPDIR
2. Runs: git rev-parse, git show, git status, git log and git diff, read-only, by argv; and the lockfile's package manager check of step 4, once per manifest without its lockfile at a commit and once per open pair at each measure, also at the turn's end
3. Sends: a note to the model after the commit's result, one more with the next prompt while a finding stands, and one line to the transcript; the package manager may ask its registry for the package metadata it resolves against
4. Persists: in $.store, the on/off setting and the mode; the package managers keep their own caches, and mix fetches into $TMPDIR/lockfile-sync/mix-deps
5. Hostile input: the directory comes from the command text and reaches git and the package manager only as the working directory, never through a shell; manifest paths reach them as one argv entry. The check runs code the project holds: a Gemfile is Ruby and a mix.exs is Elixir, and both are evaluated. npm, pnpm and bun run with --ignore-scripts, pnpm with --ignore-pnpmfile, and composer with --no-plugins, so their project scripts and plugins do not run
Limits
- Where the package manager check proves nothing, the mod compares file names and diff sections alone, and does not check that the lockfile's content matches the manifest.
- The verdict is the package manager's own:
npm cidoes not compare the root package'sversion, andyarn checkreads a lockfile that still lists a removed dependency as in step. - A Yarn 2+
yarn.lockhas no check here:yarn install --immutablelinksnode_modulesinto the project, and--mode=update-lockfiledoes not combine with--immutable. - While a finding stands, its check runs again at every main-loop turn's end, up to 60 s per pair.
- A lockfile no commit ever wrote has nothing to compare the manifest against, so only the first measure can close its finding.
- Two lockfiles of one manager in one directory (a
yarn.lockbeside apackage-lock.json) pair with the first in the table. - A commit through a script or an alias that hides
git commitis not seen.cd ~/xis not expanded. - A merge commit's combined diff is not read.
- The
denymode has no bypass. When a finding cannot be fixed, the person turns the gate off with/lockfile-sync mode note. - The gate reads any change to the lockfile in the working tree as the fix; it does not check what that change holds.
- A
git commit -a, a-amand a commit with a pathspec after--are not narrowed to the index, because they commit files the index does not hold yet. Every open pair stands for those.
Development
make install # eslint, typescript-eslint, typescript
make lint # complexity limit 10, fails the build above it
make typecheck # needs .claude/types/ from /plugin-types
make validate
make test # claude plugin test