Skip to content

pdks covenant check

pdks covenant check runs the change-set-surface judgment against the installed package. It reads the config from the working directory, takes one observation from stdin, and dispatches the same judge bodies that the session hook uses. It never opens a repository and never calls git: the caller produces the observation, the command judges it and answers with an exit code.

Terminal window
pdks covenant check [--diff | --input-failure] [--enforce advise|block] [--unattended] [--config-layer <path>] [--telemetry-path <path>]

Without an input-mode flag, stdin is the covenant input IR — the JSON document defined by the repository input schema (toolCalls, subagentSpawns, userMessages, and the two optional keys a host adds: tools, its tool roster, and session, the evidence a live agent session carries). With --diff, stdin is a unified diff and the command translates it into that IR first. --enforce is the observer’s posture for the run and defaults to advise: every break lands as a row and exit 0. --enforce block makes a protected path or an entry set to enforce: block exit 2. --config-layer names a config layer whose discipline lists are judged alongside the project’s config; a relative path resolves against the working directory. --telemetry-path names the file this run’s rows are appended to, ahead of POLYDEUKES_TELEMETRY_PATH and the config’s telemetry.logPath. Each flag at most once, in any order, and a flag’s value may not be empty or start with --; any other argument is a usage error. stdin is read to EOF.

--input-failure lets an adapter report why it could not produce an observation. stdin is a JSON object with a non-empty string reason, such as {"reason":"payload must be an object"}. The command prints that reason once, records one blocked covenant-check row, and exits 3 without judging a call. Config and telemetry-path resolution apply as usual; a config error takes precedence. --input-failure and --diff are mutually exclusive.

--unattended means no person takes part in this call: the judge does not read session.userMessages as human messages. It is accepted in any input mode, at most once; a second occurrence is a usage error. The meta-covenant witness valve (witness: { token, ttlMinutes }) never opens, and no witness state line is printed. A protected-path break under --enforce block stays blocked (exit 2). The transcript read through a { transcript: true } source carries an empty user-message list, so no declaration step, including userTexts or select/field over the transcript, sees the caller’s session.userMessages, and a witness block cannot open on user messages.

A discipline whose declaration body has a userTexts extract step records skipped with reason no-observation for each call inside its scope; calls outside its scope leave no row. If only its witness block reads userTexts, the body is judged, but that block does not open: a break stays blocked when both the entry and the call use block. Other transcript steps such as toolUses are judged as usual, and a witness block reading other evidence, such as the change’s post, can still open. Without --unattended, these judgments and valves operate as usual.

The SDK’s IR verb always passes --unattended: its caller is a program, and the text it supplies is not evidence that a person sent it. The judge cannot distinguish adapter input from SDK input, so the SDK declares this explicitly. The Claude Code hook adapter omits the flag and filters human messages itself.

The command judges exactly what stdin carries. Which diff you pipe decides the observation:

Pipe Observed set
git diff --cached | pdks covenant check --diff Staged changes — the pre-commit shape
git diff HEAD | pdks covenant check --diff The working tree against HEAD
git diff <base>..<head> | pdks covenant check --diff The change set between two refs (... for the merge-base reading)
pdks covenant check < input.json Whatever IR the caller built

The diff format is VCS-neutral: git, jj, hg, and a hand-written diff -u all produce it.

An IR built by a host carries what the repository’s disk cannot show. tools names which tool names change a file and which carry a command line (mutating, shell, commandArgs) — values, never vocabulary — and without it only the staged names a diff produces are routed. session carries the human messages with their timestamps (what the TTL witness reads), the calls already made with their outcomes (what a precedent declaration reads), the absolute path the evidence was read from (protected by equality for the run), and the spawn sidecar text. The command reads the working tree itself for every file a declaration names; an IR that supplies its own world is refused. A session input is one call of a change set the command cannot see, so a change-set declaration records skipped there, and the protected entries are compared against the stored baseline around the judgment exactly as the session surface does.

One toolCall per file block, in input order. The tool name is staged-write for a creation or modification and staged-delete for a deletion; args.file_path is the repo-relative path with one a/ or b/ prefix stripped and quoted paths unescaped.

Diff block Evidence
--- /dev/null → +++ b/P create, post = every + line
--- a/P → +++ /dev/null delete, pre = every - line
--- a/P → +++ b/P with hunks modify, pre = the - lines, post = the + lines
Same path, no hunks (a mode change) modify with empty pre and post
rename from O / rename to N staged-delete O, then staged-write N as modify over the hunks
Binary files … differ / GIT binary patch The path only — no evidence, so only path judgments apply

A modification’s pre and post are the hunk lines, not the whole file. Context lines and the \ No newline at end of file marker are dropped. Every shipped discipline that reads pre and post compares keyed lines, so the verdict is the one the whole file would give. A relation that depends on lines outside the hunk, such as two markers that must appear together or one that must be present, is judged over the hunk alone: a diff that removes one marker passes, and a line added to a file that already holds the marker can break. The session surface judges the same declaration against the whole file. A file named as a source is read in full from the world axis below, but a source names a fixed path, not the file being changed. Creations and deletions carry the full text.

The world axis is the working tree. A file a declaration names by source is read from disk under the working directory — not from the index, not from a ref. When the index and the disk differ (a partially staged file), the judged text is the disk’s. The change set (world.changes) is the list of paths whose toolCalls carry evidence.

No actor. A diff proves no author, so the translated IR carries no actor key; actor-scoped disciplines skip on this surface.

Situation Result
No covenant breaks exit 0; a toolCall no registration routed leaves one passed row under the covenant-check label
A break on a discipline entry (default advise) exit 0, one advised row, the why and one advisory summary on stderr
A break on a protected path or an entry set to enforce: block, default posture exit 0, one advised row — this surface has no valve a human could answer, and a staged gate-file change already passed the session surface
The same break under --enforce block exit 2, one blocked row
0 bytes on stdin with --diff exit 0, no rows — nothing staged is nothing to judge
0 bytes on stdin without an input-mode flag exit 3, covenant check failed closed: input is not JSON: … on stderr — an empty payload is not an IR
--input-failure with a non-empty string reason exit 3, the reason once on stderr, and one blocked covenant-check row
--input-failure with malformed JSON or a missing, empty, or non-string reason exit 3, the input error on stderr, and one blocked covenant-check row
Unparseable JSON, a non-object, a missing toolCalls, subagentSpawns, or userMessages array, or a malformed actor exit 3, one blocked covenant-check row, and the reason on stderr (covenant check failed closed: input carries no subagentSpawns array)
An IR carrying its own world key exit 3 — the world axis is the command’s to fill
A tools whose lists are missing or hold a non-string name, or a session without userMessages and toolCalls arrays exit 3, one blocked covenant-check row — a shape the command cannot judge is refused, never a default
A session whose token message is fresh under the config’s witness, without --unattended the blocked verdict lands witnessed, exit 0 — the same valve the session surface opens
A protected-path break with --unattended --enforce block exit 2, one blocked row; no witness state line
An in-scope call for a discipline whose body reads userTexts, with --unattended a skipped row with reason no-observation; no row outside its scope
A combined diff (diff --cc), an unmatched ---/+++, an unknown hunk line, or EOF before the declared hunk lines are complete exit 3, one blocked covenant-check row
Any other argument exit 2 with the usage line on stderr, stdin unread
Missing, ambiguous, or invalid config exit 2
A --config-layer file that is missing, does not parse, carries a key outside the three discipline lists, or repeats an id of the config exit 2, one blocked covenant-check row — the run is never judged without the layer
Judge body cannot load exit 2

Exit 3 means the command could not read its input, so no judgment happened. It is not a verdict, and a host that receives it still refuses the call; the stderr line names what could not be read. A config that does not load is checked first, so the same input under a broken config exits 2 with the config error.

The posture lives on the command line, not in the config, and there is no prompt. The command answers success or failure; whether a commit proceeds is decided by the hook that spawned it. A row records the verdict, not the commit’s fate — a blocked row can sit beside a commit that landed because the wiring ignored the exit code.

Each judgment appends one line to the telemetry log. A line has four or five fields separated by tabs:

Field Value
1 ISO 8601 timestamp
2 One of the six verdict words: passed · blocked · witnessed · advised · skipped · unattributed
3 Label: a meta-covenant (self-mod, shell-mod, transcript-mod), a discipline id, covenant-check for the command itself, baseline for an unattributed row, or shell-unjudgeable for a shell call no discipline could read
4 Subject: for a meta-covenant, for self-mod, the first matching protected entry in config order; for shell-mod, the entry named by its reason, not the file under it — and when the call could not be read, the first protected entry of the config or -, because an unread call routes fail-closed; for a discipline or covenant-check, the judged path; - for none
5 Optional. On skipped, the skip reason (no-observation · config-fault · supply-pass); on any other word, the witness list as a JSON array

For a change set, pass --telemetry-path with a new file on each call, and that file holds the rows of that run alone. A session input is different: its baseline comparison reads the rows the earlier calls of the session left in the same file, so a host keeps one file for the whole session rather than one per call. A caller that needs the reasons as values reads them there:

  • The exit code decides the verdict; the rows explain it. Telemetry writes are fail-open, so a row can be missing. Exit 2 with no blocked row is still a block, and no row turns exit 0 into one.
  • Protected entries are the subjects of rows labelled self-mod or shell-mod — blocked under --enforce block, advised under the default posture. A directory entry names the directory, whichever file under it the change touched.
  • Discipline breaks are blocked or advised rows labelled with the discipline id. The fifth field, when present, lists what the judgment found.
  • witnessed is a blocked verdict a human opened through the witness valve.

A witnessed session judged again as a change set. This surface has no witness valve. A protected edit a human opened on the session surface leaves witnessed⇥self-mod⇥<entry> in that session’s telemetry, and the same edit in a diff judged with --enforce block leaves blocked⇥self-mod⇥<entry>. Both surfaces name the same protected entry from the config, even when the session input named the file by its absolute path, so a caller compares the two sets of rows by entry. The diff itself needs no editing. What the comparison can say is bounded:

  • Read the witnessed rows from a file that holds that session alone. A row carries no session identity, so a shared log such as .polydeukes/roi.log mixes in other sessions and other days.
  • A match says the session opened an edit under that entry, not that the diff holds only that edit. Under a directory entry, edits to different files carry the same subject; under any entry, an edit the session never observed lands in the same diff row as the one it opened.

Terminal window
git diff --cached | pdks covenant check --diff # pre-commit, record only
git diff --cached | pdks covenant check --diff --enforce block # pre-commit, refuse a break
git diff HEAD | pdks covenant check --diff # after a task
git diff main...HEAD | pdks covenant check --diff # before a PR
pdks covenant check < input.json # an IR another program built

The observation is produced by a process outside this package, and git’s own configuration can change its text. Wire a hook with these flags so the judged text is the staged text:

Flag What it prevents
--no-color color.ui=always wraps every line in escape codes; the translator recognizes no block and the run fails closed (exit 3)
--no-ext-diff / --no-textconv diff.external or a .gitattributes textconv driver substitutes text that exists nowhere on disk, and the disciplines judge that text
--src-prefix=a/ --dst-prefix=b/ diff.mnemonicPrefix prints c/, i/, w/; the translator strips exactly a/ and b/, so any other prefix survives into the judged path

A producer that dies mid-stream leaves 0 bytes on stdin, which is the empty observation and exit 0. Where the shell supports it, set -o pipefail; in front of the pipeline makes git’s failure the hook’s failure.

A lefthook command that records every verdict and stops the commit only on a fail-closed run:

pre-commit:
commands:
covenant:
run: git diff --cached --no-color --no-ext-diff --no-textconv --src-prefix=a/ --dst-prefix=b/ | ./node_modules/.bin/pdks covenant check --diff

Append --enforce block to that line to stop the commit on a protected-path break too.

Another program embeds the judgment by spawning pdks covenant check and writing its input IR or diff to that process’s stdin; the exit code is the verdict.