shk is a small command-line interface for agents (and people) that submit and
track Slurm jobs, move data and read partition occupancy on Stanford's Sherlock
cluster, from a workstation over OpenSSH or on Sherlock itself. It is one
standard-library Python package (3.11+) plus fixed POSIX sh programs that run
on the login node. Slurm is the only source of truth: a job's name is derived
from its logical task, and shk keeps no local state, cache or ledger.
shk does not authorize resources, infer borrowed access, police what a job
script does or certify scientific results. The operating rules for agents ship
with the package (shk rules workstation, shk rules cluster); Sherlock's own
/etc/agents/AGENTS.md takes precedence over them.
Submission can opt in to explicit host/container numerical thread defaults with
--cpu-thread-budget [N] or project cpu_thread_budget = 1; the default when the
flag has no value is 1. This does not cap OS thread creation or replace Slurm CPU
isolation. See CPU thread budgets for provenance, limits and
controller compatibility.
Install from a release tag, as below; main can be ahead of the latest release.
Older installs should follow their own shk <command> --help.
shk --version prints sherlock-kit 0.5.0 (FORM), where FORM names the install:
| Form | How | FORM |
|---|---|---|
| Project environment (recommended) | pip install 'sherlock-kit @ git+https://github.com/FridrichMethod/sherlock-kit@v0.5.0', or a uv source with tag = "v0.5.0" |
git <commit> |
| Dotfiles submodule | dotfiles carries this repository as a submodule and links its bin/shk to ~/.local/bin/shk; bin/shk is a stdlib-only entry point that runs the submodule's source |
submodule <path> |
| Editable | pip install -e <path> into an environment you choose; it follows that tree |
editable <path> |
A wheel or path install reports unknown; a plain checkout run through bin/shk
reports source <path> and is for development only. A project environment's
shk takes precedence over the global one while that environment is active.
[tool.uv.sources]
sherlock-kit = { git = "https://github.com/FridrichMethod/sherlock-kit", tag = "v0.5.0" }On Sherlock, run shk with Python 3.11 or newer from a module or a $GROUP_HOME
environment; heavier installs belong in an sh_dev session.
shk never logs in. Open the control and data masters yourself first
(ssh sherlock-plain, ssh -fN sherlock-dtn); ~/.ssh/config needs
ControlMaster auto, ControlPersist yes and ControlPath ~/.ssh/%C for both
aliases. Then add the project configuration and work from the project directory:
[tool.sherlock-kit]
project = "demo"
remote_root = "/scratch/users/USER/demo"
[tool.sherlock-kit.validators]
bundle = ["python", "-m", "demo.validate_bundle"]shk doctor # offline: Python, ssh, rsync, config, both ssh masters
shk submit --task smoke --script job.sh --partition normal --cpus 1 --mem 2G --time 20m --dry-run
shk submit --task smoke --script job.sh --partition normal --cpus 1 --mem 2G --time 20m
shk status --task smoke # at least 60 s apart; the reply says when (next_check_after)
mkdir -p results # pull creates LOCAL, never its parent
shk pull /scratch/users/USER/demo/runs/NAME/out results/smoke --validate bundleCodex may need per-command approval to reach the existing SSH control socket,
even for offline doctor. A sandbox startup failure or a local socket permission
denial does not mean the user needs a new login. Approve the bounded command;
do not disable the sandbox or grant write access to all of HOME.
submit --dry-run needs no SSH socket or network access.
The job writes out/SHA256SUMS as its last step (shk manifest out, or the
sha256sum one-liner in the skill), over its output directory, never the run
directory itself; shk pull verifies every file against it before the copy
appears under results/.
shk [--project-dir DIR] [--executor auto|ssh|local] <command>
shk --version
shk doctor [--remote]
shk rules {workstation,cluster}
shk install-skill --project DIR [--client claude|codex|all] [--rules workstation|cluster] [--check] [--force]
shk occupancy --partition P [--partition P ...]
shk submit --task T [--campaign C] --script FILE --partition P --cpus N [--cpu-thread-budget [N]] --mem SIZE --time DUR
[--gpus N] [--tasks N] [--array N[%M]] [--constraint X] [--signal S] [--requeue|--no-requeue]
[--after-ok JOBID ...] [--run-dir PATH] [--courtesy-ack] [--resubmit REASON] [--submission-token TOKEN] [--hold] [--dry-run|--preflight]
shk resources --partition P [--partition P ...]
shk --executor local allocation --pid PID --job-id N_OR_N_TASK [--step-id NUMERIC_STEP] [--hostlist EXPRESSION ...]
shk release --job-id ID --expect-name NAME --expect-token TOKEN
shk requeue --self --expect-name NAME [--expect-token TOKEN]
shk status (--task T [--campaign C] [--run-dir PATH] ... | --name NAME ... | --all | --job-id ID ...) [--since DAYS] [--details]
shk cancel (--task T [--campaign C] | --name NAME)
shk push LOCAL REMOTE [--anywhere]
shk pull REMOTE LOCAL [--validate NAME] [--replace]
shk manifest DIR
- Operational results print one JSON document with
schema_version: 1on stdout;rules,--versionand help print text, and usage errors print text on stderr (exit 2).shk <command> --helpis the authoritative flag list.submit,status,cancel,occupancyandresourcesreportnext_check_after, 60 s after they ran. submit: the job name isshk.<project>.<task slug>.<hash>of project, campaign and task. One read call (squeueand the last 30 days ofsacct) decidesnew,live,done,failedorstale(spec files in the run directory but no Slurm rows, submitted likenew); a live name is always refused, a finished one unless--resubmit REASON. Then one mutation call stages the script under<run-dir>/.shk/and runssbatch; a lost reply is exit 5, and the reply'snextnames theshk statuscommand and when to run it. Defaults:--campaign default,--run-dir <remote_root>/runs/<name>,--array N[%M]with 2 to 1000 tasks.--after-okis repeatable and takes several job ids (--after-ok 111 222), each a singleNorN_TASK:shkwrites the--dependencystring itself, so a,or?list is refused.devis refused offline (exit 3batch_not_allowed): Sherlock rejects every batch job there and keeps it for interactivesh_devorsallocsessions;occupancyandresourcesstill read it.--dry-runis offline.--preflightis mutually exclusive and sends the same script/resources tosbatch --test-onlyas a read: no job, run directory or provenance is created. Itspreflightobject retains returncode, stdout and stderr; rejection is exit 3, read failure is never unknown.--submission-tokenaccepts 1-128 public characters[A-Za-z0-9_.:-](first alphanumeric), freezes it in the spec, sets Slurm Comment and exports it to the job asSHK_SUBMISSION_TOKEN.--holdfreezes a user hold and needs a token, whichreleasechecks. Tokens correlate attempts and contain no secrets.- The script's leading
#SBATCHlines may set anything except whatshkowns (-A/--account,-x/--exclude,-G/--gpus*/--gres,-p/--partition,-a/--array,-d/--dependency,-b/--begin,-J/--job-name,-H/--hold, and--commentwhen--submission-tokensets Comment) and ahetjoborpackjobseparator: heterogeneous jobs are refused. Matching follows sbatch's own parsing, so abbreviated long options (--part=gpu) and short flags bundled after value-less ones (-Hp gpu) count too. status: onesqueueand onesacctfor all selected names (--detailsadds onescontrol show jobper queued base job id);--sincedefaults to 30 days (7 with--all) and accepts 1 to 31, because Sherlock's slurmdbd refuses widersacctwindows. Give--campaignonce for every--taskor once per--task, and--run-dironce per--taskor not at all. Repeatable--job-id NorN_TASKis mutually exclusive with task, name and all selectors; it includes legacy names and only current UID rows, and lists one entry per selected base job. Without a spec file (always with--all,--nameand--job-id) an array whose seen tasks are not exactly 0 to the largest index has nullarray.count,tasks.countandtasks.missing, anddoneorfaileddescribe the seen tasks only. A preemption or restart is unexpected (exit 6) on the non-preemptible partition of the attempt it interrupted.--detailsreturns the evidence contract described below.cancelcancels the live jobs of one name.resourcesreadsscontrol show nodesonce; Sherlock ships only the nodes whosePartitions=list names a requested partition, each once. A node keeps its state, partitions, active features, configured/allocated CPU (CPUEfctv, else CPUTot), memory and GPU, CfgTRES/AllocTRES and its raw line (the evidence, soownersis megabytes: filter the JSON rather than read it whole).complete,malformed,next_check_afterand theprovenanceof the derived fields come beforenodes.free_*is configured minus allocated whatever the state (free_memory_mbfrom CfgTRES minus AllocTRES; OS FreeMem never substitutes).schedulableis false when the+-joined state has any ofDOWN,DRAIN,FAIL,MAINTENANCE,NOT_RESPONDING,RESERVED,INVALID_REG,FUTURE,UNKNOWN,REBOOT_ISSUED,REBOOT_REQUESTED,POWER_DOWN,POWERED_DOWNorPOWERING_DOWN(or their short sinfo spellings, listed inprovenance), true otherwise (PLANNEDandCOMPLETINGincluded) and null without a state: count only schedulable nodes. Missing facts are null andcomplete: false; a failedscontrolread is exit 1malformed_readwith its rc and stderr. Partition and QOS limits, reservations and owners preemption are not reflected; availability is a snapshot, not a resource reservation.allocationverifies an explicit local worker PID, own UID, physical job/array identity, current host and optional numeric step membership. It preserves actual resources, node records, raw records and hashes; missing counts remain null and no project resource policy is inferred. It refuses SSH, exited/reused PIDs and ambiguous or contradictory evidence; a scheduler that does not answer is a retryable read failure, never non-membership. See the allocation contract.releasechecks one exact ID, current UID, expected name/token and user hold immediately beforescontrol release.requeue --selfhas no arbitrary ID option: it requires the local allocation (SLURM_JOB_ID), actual Slurm PID membership in that physical job, current UID and name, the token only when--expect-tokenis given, RUNNING state, a partition whose profile allows requeue (todayowners) and Requeue=1. These are read from Slurm's record of that physical job, which also names the target: an array element is requeued as itsArrayJobId_ArrayTaskId, never the bare array ID, which would requeue every element; inheritedSLURM_ARRAY_*variables are not trusted. A refusal is exit 3 and keeps Slurm's message when a query failed; a lost or failed mutation reply is exit 5 with Slurm's message indetail. Slurm ownership checks still apply, and concurrent user changes can race the identity read. Checkpoint durability and retry limits belong to the caller.pushrsyncs LOCAL/ through the DTN into an absolute REMOTE underremote_root(--anywherelifts that) and never deletes. Pushed files keep their local modification times (rsync -t, so a re-push skips unchanged files instead of rewriting them); inputs that are old locally can therefore be purge-eligible on scratch soon after a push. REMOTE's parent must already exist. For a new remote root, push a staging directory containinginputs/to the root itself, for exampleshk push staging/ /scratch/users/USER/demowhen/scratch/users/USERexists. Check for exit 0 before submitting a job that needs those inputs.pullcopies exactly the files inREMOTE/SHA256SUMSinto a private stage beside LOCAL, verifies them, runs the optional validator and renames the stage onto LOCAL. With NAME the last component of LOCAL, the stage is.NAME.shk-stage(a rerun resumes it),.NAME.shk-lockserializes concurrent pulls, the receipt is.NAME.shk-receipt.json, and--replacekeeps the previous copy as.NAME.shk-old-<UTC>; projects can gitignore.*.shk-*. The validator runs from the project root (the directory of the configuration file) with the stage directory as its last argument, passes with exit 0 and must treat the stage as read-only: whatever it writes is promoted with the data.occupancylists current use of partitions. Borrowed partitions (btrippe,possu) need it and--courtesy-ackbefore a submission, and such a submission reports their occupancy and courtesy text, with a warning when others are running there and you already have 2 jobs there or ask for more than 2 h. Releasing a job held there counts as submitting it:releasechecks neither occupancy nor the clock, so check both first.install-skillcopies the agent skill into a project (.claude/skills/and.agents/skills/) and, with--rules, the rules into.claude/rules/(Codex gets a hint instead);--checkexits 1 when a copy is missing or stale.
status --details adds details = {queue, accounting, jobs, complete, queried_at, history_window_days, missing_job_ids, missing_control_job_ids, selection, allocation_evidence_complete, history_complete, control_identities_complete, returncodes, malformed}. Queue and
accounting are lists of full records, before summary deduplication; every
record includes the original raw line, whose fields are separated by the
ASCII unit separator (0x1F) so that | in comments and job names stays data.
Their normalized fields are
job_id, job_id_raw, cluster, db_index, state, partition,
elapsed_seconds, allocated_cpus, allocated_tres, submitted_at,
started_at, ended_at, restarts, exit_code, nodes, name, comment
and uid. Missing fields are null, numeric facts are integers, timestamps
are UTC strings, IDs (including DBIndex) remain strings. Queue CPUs and start
are null for a job without nodes (pending, or cancelled before any
allocation), and a live job's end is null, because squeue prints requests,
placeholders or predictions there. Sherlock's accounting Comment is empty,
so details shows a submission token only in queue and control records while
the controller holds the job; the frozen spec keeps it. Slurm also records the
sbatch command line, which carries the token, as SubmitLine (in control
records, and in sacct where slurmdbd stores it).
jobs holds current-UID scontrol records of queued physical IDs as {job_id, name, comment, uid, fields, raw}, read with one show job per base job id: an
array ID prints every element the controller still holds, records outside the
selection are ignored, and each queued physical ID needs its own record.
fields retains original Slurm names and string values (times in UTC),
including request TRES, Features, TimeLimit, Requeue and array identity. All
records are restricted to the current UID. returncodes holds each tool's
exit status. A failed squeue or sacct read, or a selected name none of
whose rows parse, exits 1 malformed_read like plain status, with details and
without names. A failed control read, missing requested IDs, malformed rows or
missing accounting cost identity make complete false and retain available
facts; a failed control read alone still exits 0.
Accounting rows of jobs that never had an allocation (AllocCPUS 0, ElapsedRaw
0, no Start and no AllocTRES, in any state such as PENDING or
CANCELLED by UID) are structurally complete, and a terminal one has a final
zero cost. Missing allocation TRES after a job started and restart-history
gaps are incomplete.
--job-id lists one entry per selected base job, in job id order; legacy jobs
often share a script name, so duplicate_live (across the selected jobs) and
unknown_partition apply to shk. names only. Each entry's
current.evidence_complete covers its own job (accounting identity and
allocation, control records, restart history, partial scope) and malformed
lines that no job can be told apart from; cost.final requires it, while
complete is the conjunction over the whole query. A requested ID without
readable rows (a hetjob's N+k, for example) has no entry and is listed only in
missing_job_ids, so a batch's cost is final only when that list is empty and
every entry has cost.final. By task or name, an unreadable row withdraws only
its own name's final cost.
An exact array-element selection reports selection.partial_array_ids, entry
scope: partial_array, unknown whole-array state/count/missing values, and
nonfinal cost; sibling states cannot be inferred from a selected element.
Without a spec file an array's task count is inferred only when the seen tasks
are exactly 0 to the largest index; otherwise (1-based or stepped legacy
arrays, early tasks gone from the window) array.count, tasks.count and
tasks.missing are null, the state follows the seen tasks and the cost is not
final. A cancelled array that never split is one squeue row without a task
suffix, and names-mode sacct returned no rows on Sherlock for jobs held until
cancelled, so such a names-mode summary can count one task; --job-id reads
the accounting aggregate.
A complete query does not establish unlimited historical coverage: it is bounded by
history_window_days, and array cardinality without provenance is inferred.
A bounded deterministic workflow manager may run inside its allocated Slurm
job and call shk --executor local to submit/poll authorized later compute
stages at intervals of at least 60 seconds, with no workstation dependency.
This does not authorize scheduling future agent or LLM sessions. Controllers
own their bounded retries, checkpointing, scientific validation and accounting.
[tool.sherlock-kit] in pyproject.toml, else the top level of
sherlock-kit.toml, found with --project-dir DIR (exactly that directory) or
in the nearest directory at or above the working directory that has one.
Unknown keys are errors; nothing secret belongs here.
| Key | Default | Used by |
|---|---|---|
project |
none; ^[a-z0-9][a-z0-9-]{0,23}$ |
submit, status --task, cancel --task; status --all lists only this project's names |
remote_root |
none; absolute, no .. |
submit (default run directories, also for status), push (unless --anywhere) |
control_host |
sherlock-plain |
every remote call |
data_host |
sherlock-dtn |
push, pull |
connect_timeout, command_timeout, transfer_timeout |
15, 60, 3600 s | ssh and rsync |
cpu_thread_budget |
none (off); positive integer, at most --cpus |
submit numerical thread defaults per task, overridden by --cpu-thread-budget; see CPU thread budgets |
[tool.sherlock-kit.validators] |
none; NAME = ["argv", ...] |
pull --validate NAME runs argv plus the stage directory, only from a configuration file you own |
doctor, rules and occupancy work without a configuration. On Sherlock
(sh<N>-ln<N> login nodes, SLURM_CLUSTER_NAME=sherlock, a compute node inside a
job, or SHK_EXECUTOR=local) the same programs run locally and push and pull
are refused.
| Code | Meaning |
|---|---|
| 0 | ok |
| 1 | local error: configuration, spec, verification, validator, malformed reply |
| 2 | usage error |
| 3 | refused by policy or Slurm: live duplicate, finished task without --resubmit, missing --courtesy-ack, an #SBATCH directive shk owns, Slurm rejected the job, push or pull on Sherlock |
| 4 | SSH transport or master check unavailable: auth_required (ask the user to open the master); unavailable with a local permission denial (check the sandbox; request per-command approval in Codex); unavailable with a ~/.ssh/config hint (fix the client config); other unavailable or timeout, hint run shk doctor (run it, retry once after a minute, then stop) |
| 5 | outcome unknown after a dispatched mutation: run the shk status command in the reply's next, not before the time it names (at least 60 s) |
| 6 | status ok, but the newest job of a requested name has an anomaly |
Errors print {"ok": false, "error": TOKEN, "message": ..., "hint": ...}.
- Rules:
src/sherlock_kit/data/rules/workstation.md(agents on a workstation) andcluster.md(agents on Sherlock), at most 7 KiB each, each ending with the partition profiles generated fromdata/partitions.json. - Skill:
src/sherlock_kit/data/skills/sherlock-kit/SKILL.md, for Claude Code and Codex alike. - Global delivery: dotfiles links both into
~/.claude/rules/,~/.claude/skills/and~/.agents/skills/. Per project:shk install-skill --project ..
python3 -m venv .venv && .venv/bin/python -m pip install -e '.[dev]'
.venv/bin/pre-commit install
.venv/bin/pre-commit run --all-files # ruff, mypy, shellcheck, shfmt, mdformat
python3 -B tests/run_suite.pyThe suite is offline: fake ssh and Slurm binaries, real local rsync, sh and
dash. A skipped test fails the run when CI or SHK_REQUIRE_ALL is set.
SHK_BUILD_PYTHON (an interpreter with setuptools>=80) enables the sdist,
wheel and editable-install checks, which build committed HEAD. See
CONTRIBUTING.md, docs/design.md,
docs/sherlock-policy.md,
docs/cpu-threads.md,
observed Slurm behavior and
CHANGELOG.md.
Licensed under MIT.