Contributing
Thanks for your interest in improving claude-nomad. It is a small TypeScript CLI, and the contributor workflow is deliberately lightweight: clone, install, make a change behind the five gates, and open a pull request. The machine-enforced configs (linked throughout) are the source of truth, so this guide stays short and points at them rather than restating values that could drift.
Development setup
Section titled “Development setup”Node is pinned via the engines field in package.json;
use that version or newer.
git clone git@github.com:funkadelic/claude-nomad.gitcd claude-nomadnpm cinpm ci runs the prepare script, which initializes husky so the git hooks are active on a fresh
clone. Two hooks then fire automatically on git commit:
.husky/pre-commitrunslint-staged: eslint and prettier on staged*.ts, markdownlint and prettier on staged*.md, and prettier on staged JSON/JS..husky/commit-msgruns commitlint against the commit message.
Before opening a PR, run the five gates locally:
npm run formatnpm run lintnpm run typechecknpm run testnpm run lint:mdDocs-sync gate
Section titled “Docs-sync gate”A separate CI check (.github/workflows/docs-check.yml, backed by
scripts/check-docs-sync.cjs) enforces that CLI changes ship with documentation.
The canary is src/nomad.help.ts (the authoritative DEFAULT_HELP usage block):
when a PR changes it, the check fails unless the PR also updates a docs surface
(README.md or docs-site/src/content/docs/commands.md). New flags and commands
edit the help block, so this catches the common case of adding a flag without
documenting it. If a change genuinely needs no docs, add the docs-not-needed
label to the PR to bypass the gate (locally, run the command with
DOCS_CHECK_BYPASS=1).
Dependency management
Section titled “Dependency management”The policy below is already encoded in the configs; this section records the reasoning so it does not have to be reverse-engineered from them.
- Ranges express intent, the lockfile guarantees installs. Dependencies in
package.jsonuse caret (^) ranges to state compatibility intent, while the committedpackage-lock.jsonis the single source of reproducible installs. CI and the documented setup usenpm ci, which installs the locked tree exactly and fails if the lockfile and manifest disagree. Always commit the lockfile changes that an install produces. - Dependabot drives updates.
.github/dependabot.ymlopens weekly update PRs for both thenpmandgithub-actionsecosystems, so bumps are reviewed rather than applied by hand. To keep the noise down it batches updates into grouped PRs and routes the commit prefixes (deps/deps-dev) through release-please so the bumps land under the changelog’s Dependencies section. @types/nodemajors are held back on purpose. The config ignores@types/nodemajor bumps so the type surface stays pinned to the lowest supported runtime. Letting it float would let a newer-Node-only API typecheck cleanly and then crash at runtime on the supported floor.- Hard pins are reserved for behavior-sensitive externals that are not npm range deps. Two
cases are pinned exactly rather than ranged: the gitleaks version, kept as a single
GITLEAKS_PINNED_VERSIONinsrc/config.tsand mirrored in both workflow YAMLs, withsrc/config.gitleaks-pin.test.tsasserting the three stay in lockstep so a CI bump that misses the constant fails the suite; and first-party GitHub Actions, which are SHA-pinned for supply-chain integrity (Dependabot still proposes the bumps). - Do not exact-pin runtime dependencies in
package.json. claude-nomad is published to npm, so pinning a runtime dependency to an exact version blocks consumers from deduping it against their own tree and adds upgrade-PR churn that the committed lockfile already makes unnecessary. Pin in the lockfile (automatic), not in the manifest ranges.
One grouping choice is deliberate and worth stating: Dependabot groups dev-dependency minor and
patch updates and production patch updates into single PRs, but a production minor update
arrives as its own PR. Production minors are the likeliest to carry behavior change, so they get
individual review while the lower-risk batches stay consolidated.
Mutation testing
Section titled “Mutation testing”Stryker flags tests that kill zero mutants across a module’s mutation report. A zero-kill test is one that no code change in that module could cause to fail; it is a candidate for removal if it is redundant with a richer sibling. Use this workflow to identify and triage those candidates.
Mutation testing is local-only. Runtime is 20 to 60 minutes for a full sweep, so it is never wired into CI. Run it as a hygiene exercise, not as part of the normal development loop.
Running per-module
Section titled “Running per-module”The committed
stryker.config.mjs
contains the project defaults. Run one module at a time, always scoping both the mutate target and
the test files:
npx stryker run --incremental --force \ --mutate "src/<module>.ts" \ --testFiles "src/<module>.test.ts"The --testFiles scope is required. Without it Stryker runs the full suite as the dry-run baseline
and the dry run fails on developer machines (the full suite is not idempotent under the Stryker
sandbox).
Reports land in reports/mutation/ (gitignored). Archive each module’s
reports/mutation/mutation.json under a per-module name (for example
reports/archive/<module>.json) before moving to the next module, or the file will be overwritten.
The reports/stryker-incremental.json incremental cache accumulates across sessions so you can
resume a multi-session sweep without re-running completed modules.
Mutation testing and HOME-based test isolation
Section titled “Mutation testing and HOME-based test isolation”src/config.ts resolves
home(), claudeHome(), and repoHome() on every call, reading process.env.HOME (and
USERPROFILE on win32) before falling back to os.homedir(). That read is deliberate: it lets a
test swap process.env.HOME to a tmpdir and see the change immediately, including inside Stryker’s
pool: 'threads' worker isolates where os.homedir() stays blind to an in-process env swap. So
HOME-swapping tests mutation-test normally; an earlier limitation here (config resolved at module
load, so the swap did not take effect) was removed by moving to call-time resolution.
The one value fixed at module load is HOST (NOMAD_HOST or the machine hostname, resolved once at
import). A test that needs a different host label sets NOMAD_HOST before reloading the module with
vi.resetModules() rather than relying on a bare hostname swap.
Triage
Section titled “Triage”After running a module, list zero-kill candidates:
node scripts/find-zero-kill-tests.mjs reports/archive/<module>.jsonscripts/find-zero-kill-tests.mjs
emits one ZERO-KILL line per candidate and exits 0 (no output means every test in the report
kills at least one mutant).
Review each candidate against the keep/delete criterion:
- Delete only when the test is redundant with a richer sibling in the same file, a literal duplicate, or a narrow early test that is fully subsumed by a later broader test.
- Keep when the test pins a distinct documented behavior (for example, a specific error path or an empty-input contract), guards a branch that Stryker does not mutate, or is the sole documentation of a behavioral contract.
Zero-kill results from subprocess-based tests (for example, commands.adopt) are expected false
positives: Stryker cannot observe kills that happen inside a spawned child process. Keep those
tests without further analysis.
Security modules default to keep. Tests in src/push-checks.ts, src/push-gitleaks*.ts,
src/commands.redact*.ts, src/commands.push.recovery*.ts, src/utils.lockfile*.ts, and
src/config.sharedDirs.guard.ts are never bulk-deleted. A zero-kill result in a security module
often documents a refusal or containment invariant that mutation testing does not exercise (for
example, a traversal-guard rejection path). Delete a security-module test only with an explicit
recorded rationale.
Coverage guardrail
Section titled “Coverage guardrail”After each deletion, run:
npm run coverageIf the deletion uncovers lines in the touched source file, revert it. The test was load-bearing for
coverage, not dead weight. The project coverage gate must not regress: a fully-covered file is
absent from the coverage text table (skipFull), so absence is the pass signal.
Branch naming
Section titled “Branch naming”Branch off main with a <type>/<slug> name, where <type> is a Conventional Commit type (for
example feat/path-remap-fix, fix/lockfile-race, docs/contributing). Do not commit directly
to main.
Commit messages
Section titled “Commit messages”Commits follow Conventional Commits:
<type>(optional scope): subject, imperative mood, no trailing period. The enforced type list
(which extends the conventional set with deps and deps-dev) lives in commitlint.config.js;
that config, run by the commit-msg hook, is what passes or fails your message. Keep the subject
under about 72 characters. Bodies and footers are free-form prose: the per-line length caps are
disabled in the config, so write paragraphs as single long lines and let the renderer soft-wrap.
Pull requests
Section titled “Pull requests”Keep PRs terse. The body is a short Summary, one or two bullets of what changed and why; GitHub pre-fills the template. Do not add a test-plan checklist, do not paste metrics that CI already reports, and do not include attribution trailers. The PR title must itself be a valid Conventional Commit subject (a CI check enforces this).
Releases
Section titled “Releases”Releases are automated by release-please, which reads Conventional Commit types to decide both the version bump and the changelog grouping:
feattriggers a minor bump; a!suffix or aBREAKING CHANGE:footer triggers a major bump; every other Conventional Commit type in the config (fix,perf,docs,refactor,test,build,ci,chore,style,deps,deps-dev) triggers a patch bump.- The type only decides which changelog section the entry lands in, not whether a release
happens. None of these types is marked
hiddeninrelease-please-config.json, so any one of them landing onmainis enough to cut a release. The full type-to-section mapping lives there.
Two per-PR escape hatches override the computed version when you need them: add a
Release-As: <version> footer to force a specific version, or wrap a replacement message in a
BEGIN_COMMIT_OVERRIDE / END_COMMIT_OVERRIDE block in the squashed PR body.