Configuration reference
Every key of polydeukes.config.yaml, one section per key. The guide — what the file is,
how discovery fails, and the IDE wiring — is
Configuring Polydeukes, and what a verdict looks like when a
discipline fires is its
What enforcement looks like
section.
For the complete syntax of relations and extraction steps, see the Declaration language reference.
languages
Section titled “languages”Required. The language axis, first-class. Keys are your values (typescript, python, …) —
the core ships no language names and never interprets the command string.
languages: typescript: productionGlob: 'packages/*/src/**/*.ts' # what counts as production source testCmd: 'pnpm --filter {scope} test' # {scope} is substituted at resolve timetestCmd is a template string, not a function. Every {scope} occurrence is replaced;
all other braces (${VAR}, {a,b}, awk '{print}') pass through untouched. A command
that ignores scope (pnpm test) is equally valid.
protectedPaths
Section titled “protectedPaths”Optional. Raw path patterns whose files the covenants protect from modification — by
editor tools and by shell commands alike (sed -i, tee, redirects, heredocs,
parent-directory moves). Entries are normalized (trimmed, deduplicated) at resolve time.
An empty-string entry is rejected at load time — it carries no path meaning.
protectedPaths: - 'packages/core/src' - '.claude/hooks'The config file protects itself. The discovered config file is automatically appended
to protectedPaths — an edit that would lower your own gates goes through the same judge
as everything else. If the file that declares the disciplines were not itself under the
disciplines, the whole chain would be decoration.
adapters
Section titled “adapters”Optional. Adapter namespaces. One config file, one namespace per adapter: each key names an adapter, and its value is that adapter’s own settings object. The core validates the container shape only — the keys and the contents belong to each adapter, which ships its own validator for its own vocabulary. An unknown key inside a namespace is rejected by that adapter’s validator, with the full field path in the error.
adapters: example: someKey: 'a value the example adapter defines'protectedPaths is one list for both surfaces. There is no additive change-set-only scope
and no surface-level enforcement key in the config. The change-set surface judges the same
normalized list the session surface does. A violation exits 2 on the session surface; on the
change-set surface it lands advised at exit 0 unless the check runs with --enforce block. The
judge emits only that exit code: whether a commit stops is the user’s hook wiring, so a
telemetry row records the verdict, not the commit’s fate — a blocked row can sit beside a
commit that proceeded because the wiring ignored the exit code.
The session surface (the editor-time hook) has no level setting either. What it blocks is the
judging chain’s own protection — protectedPaths mutations and mentions on the tool and
shell axes, the session transcript, an assembly that cannot judge (missing or invalid
config, unbuilt judge, unparseable payload, a routing that could not answer) — plus any
entry promoted with enforce: block. Every other discipline entry lands advised there.
A declaration reaches only the surface that observes its channels. A transcript reader is
written in sessionDisciplines and is never compiled for a change set; a changes reader is
written in changeSetDisciplines and is never compiled for one call. The three lists and the
rule that places an entry in one of them are in the three discipline
lists.
A declaration’s supply policy still governs a channel the surface registers but the host
does not prove. A host that carries no session (the Grok adapter) leaves a
sources: { session: { transcript: true } } entry’s source absent, and supply: { session: 'pass' }
records a skipped telemetry event carrying the reason token supply-pass and lets the call
proceed. Without that policy a missing source is unjudgeable (exit 2), not an automatic skip.
telemetry
Section titled “telemetry”Optional.
telemetry: logPath: '.polydeukes/roi.log' # default when omitted; keep it gitignoredEvery judgment — passed, blocked, witnessed, advised, or skipped — appends one record.
Telemetry is fail-open by design: a logging failure never changes a verdict. The path
itself is still validated at load time — an empty or whitespace-only logPath is
rejected.
witness
Section titled “witness”Optional.
witness: token: 'pdks witness' # the phrase a human types in the conversation ttlMinutes: 10 # validity window, in minutes, from that messageA human can open the witness valve by typing the configured token in the conversation.
Blocked judgments may then proceed for ttlMinutes from that message’s timestamp. After the
window expires, blocking resumes. Both keys are required when this section is present:
token must be non-empty after trimming, and ttlMinutes must be finite and greater than zero.
The judge runs before checking the valve. Only a blocked judgment can become witnessed;
passed and advised judgments keep their original result.
The token must stand alone on the message’s first line. Invoking the witness is distinct from talking about it: a message that quotes, questions, or explains the token mid-sentence — or wraps it in backticks — does not open the valve, while a first line carrying the token alone does, with any following lines free for the work itself.
A message that invokes — the token alone on the first line, the rest free:
pdks witness
now fix the hook fileA message that merely mentions — the valve stays shut:
so when does `pdks witness` expire?The token’s value is free — any phrase works, and it is never checked for a prefix or a command shape. Only its placement is constrained.
The adapter must identify the token’s message as human input before it can open the valve.
Knowing the token alone is insufficient. Every judgment allowed through the valve is recorded
as witnessed.
The three discipline lists
Section titled “The three discipline lists”Choose the list according to the evidence channels the declaration reads. Each surface supplies different channels; the loader rejects an entry in a list whose surface cannot supply them.
| List | Judged on | What its declarations read |
|---|---|---|
disciplines |
both surfaces | the changed file itself (target.path · pre · post · state) and file sources — nothing else. Drafts live here too |
sessionDisciplines |
the session surface only | at least one of command · transcript · the sidecar channel · actor, and never changes |
changeSetDisciplines |
the change-set surface only | changes, and no session channel |
The session surface is one call a host observed before it ran — an adapter hook, or a program
calling the SDK with an input IR. It carries the command line, the conversation history, the
spawn-record channel, and the actor, and it does not carry the finished change set. The
change-set surface is pdks covenant check --diff over a change set some producer finished.
It carries changes, and it carries no command line, no history, and no actor.
Channel to list
Section titled “Channel to list”Each channel appears in a declaration in a fixed syntactic position, and the loader reads those positions to derive the list.
| Channel | How it appears in a declaration | List |
|---|---|---|
transcript |
sources: { session: { transcript: true } } |
sessionDisciplines |
channel |
sources: { spawns: { sidecar: true } } |
sessionDisciplines |
command |
scope: { source: 'command' }, or { op: 'source', of: 'command' } in any pipeline |
sessionDisciplines |
actor |
{ op: 'source', of: 'actor' } in any pipeline |
sessionDisciplines |
changes |
{ op: 'source', of: 'changes' } in any pipeline |
changeSetDisciplines |
| none of the five | the declaration reads the changed file and file sources alone |
disciplines |
A witness block’s own extract is walked with the body’s, so a valve reading the transcript
puts its entry in sessionDisciplines like any other transcript reader.
The placement rule
Section titled “The placement rule”disciplines takes an entry binding none of the five channels, plus every draft.
sessionDisciplines takes an entry binding at least one session channel and not changes.
changeSetDisciplines takes an entry binding changes and no session channel. Anything else
is a ConfigValidationError at load time, on both surfaces, and the message names the entry,
the channels it reads, and the list it belongs in:
disciplines[7] ('merge-is-the-users-call') reads transcript, command: it belongs in sessionDisciplinesA declaration reading both changes and a session channel is invalid in every list,
because no surface observes both at once:
sessionDisciplines[2] ('pairs-across-a-session') reads transcript, changes: no surface observes both changes and a session channelA draft binds nothing — it carries no declaration — so it belongs in disciplines, and a
draft written in either surface list is refused with a draft belongs in disciplines.
Ids are unique across all three lists and the three meta-covenant labels (self-mod ·
shell-mod · transcript-mod): the telemetry label space is one space, and pdks explain
and every label-keyed reader index by the label alone.
What each surface compiles
Section titled “What each surface compiles”pdks covenant check reading an input IR on stdin compiles disciplines followed by
sessionDisciplines; the same command with --diff compiles disciplines followed by
changeSetDisciplines. pdks explain prints both surfaces with each list’s name and count in
the surface header, so the placement of every entry is readable without running a judgment.
This repository’s live config places 11 judged entries and 1 draft in disciplines, 13 in
sessionDisciplines, and 1 (docs-stay-bilingual) in changeSetDisciplines — 24
registrations on the session surface and 12 on the change-set surface.
disciplines: # both surfaces - id: 'covenant-vocabulary' declare: mechanism: 'added-only' scope: { source: 'target.path', include: ['^packages/'] } supply: { pre: 'empty', post: 'empty' } extract: before: [{ op: 'source', of: 'pre' }, { op: 'lines' }] after: [{ op: 'source', of: 'post' }, { op: 'lines' }] added: [{ op: 'onlyIn', of: 'after', notIn: 'before' }] relate: - { id: 'nothing-added', relation: { op: 'empty', of: 'added' }, message: 'adds {value}' }
sessionDisciplines: # the session surface only — reads command - id: 'pnpm-only' declare: mechanism: 'forbidden-command' scope: { source: 'command' } extract: hits: - { op: 'source', of: 'command' } - { op: 'lines' } - { op: 'matches', re: '\bnpm install\b' } relate: - { id: 'no-npm', relation: { op: 'empty', of: 'hits' }, message: '{value}' }
changeSetDisciplines: # the change-set surface only — reads changes - id: 'docs-stay-bilingual' declare: mechanism: 'companion' scope: { source: 'target.path', include: ['\.md$'] } extract: en: - { op: 'source', of: 'target.path' } - { op: 'keyByPattern', re: '^(.+?)(?<!\.ko)\.md$' } koChanged: - { op: 'source', of: 'changes' } - { op: 'items' } - { op: 'keyByPattern', re: '^(.+)\.ko\.md$' } relate: - id: 'ko-follows' relation: { op: 'implies', of: 'en', requires: 'koChanged' } message: '{value} changed without {key}.ko.md'Moving an entry between lists is the whole edit: the entry’s body is unchanged by the move, and the loader is what says whether the new place is the right one.
disciplines
Section titled “disciplines”Optional. Each entry is one discipline: a practice the team imposes on itself, declared as
data. Everything in this section applies to an entry in any of the three lists; which list an
entry is written in is decided by the placement rule, and
sessionDisciplines and changeSetDisciplines take the same entry shape as disciplines
minus the draft form. A judged entry carries a declare block — the one judged form, a
declaration whose scope lives inside the block — an id (the telemetry label), and
optionally a why (the
reason, which travels with the block message the agent reads) and an enforce level. The
closed key set is id · why · enforce · declare; any other key is refused.
draft — an unpromoted entry. The one shape that carries no predicate:
{ id, why, draft: true } and nothing else. A draft registers a practice as prose ahead of
its promotion — it makes no judgment and no telemetry record on either surface, and
pdks explain shows it as unpromoted. why is required here (the prose is the entry’s
whole body), and the marker must be the literal true — a draft is declared, never
inferred, so an entry with neither a predicate nor draft: true is still a validation
error, and draft: false is rejected as dead data.
disciplines: - id: 'benchmark-supports-performance-claim' why: 'a performance claim must be supported by a fresh benchmark run during judgment.' draft: trueA bilingual mirror pair is not a draft. The engine already judges that shape as companion
(this repository’s docs-stay-bilingual entry). Use draft: true only for a promise the
current grammar cannot express.
A why is never judged — it changes no verdict. It is appended to the break message once a
verdict has blocked, so whoever reads the block gets the rationale in the same line instead
of having to open this file. A why spanning several lines is folded to spaces: the message
is one line.
enforce — the entry’s own level. Optional on any judged entry: block or advise.
Absent means advise. Under advise a break is recorded as an advised telemetry
event and the call proceeds (exit 0), with the break message still written to stderr;
block is the promotion — it pins the entry at block. The config carries no surface-level
enforcement key: absent or advise means advised on both surfaces, block means exit 2 on
the session surface and on a change-set check run with --enforce block (the change-set
surface’s default posture is advise for every verdict). An unjudgeable body (never
built, or one that cannot be loaded) still blocks whatever the level. A draft carries no
enforce; any
other value is rejected at load time. pdks explain prints the level an entry declares
(enforce: block or enforce: advise) on both surfaces and leaves an absent one unmarked;
the session header states the default.
- id: 'hooks-stay-armed' why: 'a command that disarms or reroutes the git gate is a gate bypass in itself.' enforce: advise declare: mechanism: 'forbidden-command' scope: { source: 'command' } extract: hits: - { op: 'source', of: 'command' } - { op: 'lines' } - { op: 'matches', re: 'LEFTHOOK=(0|false|no|off)\b|core\.hooksPath' } relate: - { id: 'gates-armed', relation: { op: 'empty', of: 'hits' }, message: '{value}' }Added-direction content is a declaration. A promise about what an edit adds — a
banned word, a stray .only, a citation that resolves nowhere — is written as an
added-only declaration: pre and post are each cut into lines and keyed by the match,
onlyIn keeps what post has and pre lacks, and empty over that difference is the
verdict. Existing occurrences are forgiven, so adopting the discipline never blocks a legacy
codebase. supply: empty is what lets a file creation (no pre) count as all-added and a
deletion (no post) as adding nothing; the scope block replaces in/except with
regular expressions over the path. It takes two lists, include and exclude: a path is
in scope when it matches at least one include pattern (an absent include admits every
path) and matches no exclude pattern. excludeIgnoreCase: true makes the exclude
patterns case-insensitive; include is always case-sensitive.
disciplines: - id: 'no-focused-tests-in-src' why: 'a focused test must not land in shared source.' declare: mechanism: 'added-only' scope: { source: 'target.path', include: ['^src/', '^test/'] } supply: { pre: 'empty', post: 'empty' } extract: before: - { op: 'source', of: 'pre' } - { op: 'lines' } - { op: 'keyByPattern', re: '(\.only\()' } after: - { op: 'source', of: 'post' } - { op: 'lines' } - { op: 'keyByPattern', re: '(\.only\()' } added: - { op: 'onlyIn', of: 'after', notIn: 'before' } relate: - id: 'nothing-added' relation: { op: 'empty', of: 'added' } message: 'adds {key}: {value}'The key is the match text, so a line carrying a word the file already has anywhere is forgiven. A line carrying two new words surfaces only the first match; the second appears on a later judgment after that first match is removed. Re-judging the same input yields the same first match.
A frozen path is a declaration too. A file that may be created once and never modified
or deleted: pre present means a modification, post absent means a deletion, and either
breaks. Creating the file, including with empty contents (post: ''), passes.
- id: 'archived-records-stay-frozen' why: 'an archive that can be edited is not an archive.' declare: mechanism: 'self-absolution-ban' scope: { source: 'target.path', include: ['^records/archive/'] } supply: { pre: 'empty', post: 'empty' } extract: prior: [{ op: 'source', of: 'pre' }] here: [{ op: 'source', of: 'target.path' }] after: [{ op: 'source', of: 'post' }] deleted: [{ op: 'onlyIn', of: 'here', notIn: 'after' }] touched: [{ op: 'union', of: ['prior', 'deleted'] }] relate: - { id: 'frozen', relation: { op: 'empty', of: 'touched' }, message: '{value} is frozen' }A command line is a source. On the session surface a shell call carries its command
line as the fixed source command, and a call that changes no file is still one
observation — it is judged as a world of its own, with subject -. A forbidden-command
declaration reads that source, cuts it into lines, keeps the lines a pattern matches, and
requires the result to be empty. It scopes on command so that only shell calls are
admitted: an Edit carries no command line, and a declaration reading a source its world
lacks is unjudgeable. A multi-line command is judged line by line, so ^ means the start
of a line; a pattern that would span a line boundary does not match. Heredoc bodies and
herestring words are not part of the source: bash passes them to the command as stdin
data rather than executing them, so a banned token quoted inside one is not a match, and
whether that data becomes a file write is judged by the shell-evidence path instead. Text
bash expands first stays in the source — a body under an unquoted delimiter that carries
$ or a backtick, and a herestring word holding a substitution — as does a line the
tokenizer could not finish reading. What the receiving command does with its stdin is not
judged: a script fed to an interpreter is data to bash and a program to the interpreter.
- id: 'hooks-stay-armed' why: 'a command that disarms or reroutes the git gate is a gate bypass in itself.' declare: mechanism: 'forbidden-command' scope: { source: 'command' } extract: hits: - { op: 'source', of: 'command' } - { op: 'lines' } - { op: 'matches', re: 'LEFTHOOK=(0|false|no|off)\b|core\.hooksPath' } relate: - { id: 'gates-armed', relation: { op: 'empty', of: 'hits' }, message: '{value}' }A precedent is a declaration over the session. Most declarations ask “is this change
itself bad”; a precedent asks whether a required step happened earlier in the session.
The change is legitimate — what is missing is the procedure in front of it, so what gets
judged is the session history: sources: { session: { transcript: true } } hands the
declaration the user turns and tool calls as one snapshot, toolUses picks the calls,
filter keeps the ones that ran and succeeded, select reaches the command line, and
matches finds the required one; nonEmpty is the verdict. A call the covenant blocked,
one a human refused, and one that simply failed are not precedent. The pattern is matched
anywhere in a command line — a line that merely mentions the command counts, a declared
limit. supply: { session: 'pass' } is what disposes of an absent session on a host that proves
none; an entry reading the transcript is written in sessionDisciplines, so a change set never
compiles it at all.
- id: 'dependency-needs-npm-view' why: 'a dependency version must be measured before it is written.' declare: mechanism: 'precedent' scope: { source: 'target.path', include: ['^(packages/[^/]+/)?package\.json$'] } sources: { session: { transcript: true } } supply: { session: 'pass' } extract: npmView: - { op: 'source', of: 'session' } - { op: 'toolUses', names: ['Bash'] } - { op: 'filter', when: [{ field: 'succeeded', eq: true }] } - { op: 'select', path: 'args.command' } - { op: 'matches', re: '\bnpm view ' } relate: - { id: 'npm-view', relation: { op: 'nonEmpty', of: 'npmView' }, message: 'no successful npm view precedes this manifest edit' }A tool call is evidence the same way: toolUses without names, then field name and
matches over the tool’s name, or toolUses with subagentType for a spawn of one agent
kind. The other history mechanisms read the same snapshot — phase-order relates two
spawn ordinals with ordered, turn-locality keeps the user turns inside a time window
(userTexts → ageMs → filter lte), and stated-ground requires a user turn matching a
pattern; the last two are usually scoped on command, so that only the shell call they
apply to is judged.
A caution on line anchors. A declaration’s lines step splits the text first, so ^
inside keyByPattern or matches after it is the start of a line. A pattern that stops
mid-value — say at the first digit of a version — keys 4.0.5 and 4.0.6 alike, so a bump
adds nothing to an added-only difference and the discipline silently passes: make the
pattern span the whole value that can change. Both failure shapes compile, run, and answer
passed, so measure a new entry against a real file and a realistic edit.
Witnesses and precedent evidence are different. A pattern seeking precedent evidence must distinguish the required action from a mere mention. Session evidence lives on the AI’s own surface, so it is not forgery-proof; the design relies on the least effortful way to satisfy the check being to actually run the command, which is the behaviour the discipline exists to induce. A pattern alone does not eliminate the possibility of forged evidence. Test both valid and violating cases.
declare — declaration family. One judgment written as data, in the algebra grammar
the core publishes as algebra-declaration.schema.json: judge = relate ∘ extract. The
block carries the declaration’s scope, sources, supply, extract, relate, and optional
witness; the entry’s id is the declaration’s name, so the block never carries a
discipline key, and in/except/when are refused — the scope block is the scope.
- id: 'db-files-only-under-data' why: 'a *.db file may exist only under data/' declare: mechanism: 'naming' scope: { source: 'target.path', include: ['\.db$'] } extract: outside: - { op: 'source', of: 'target.path' } - { op: 'matches', re: '^(?!data/)' } relate: - id: 'placed' relation: { op: 'empty', of: 'outside' } message: '{value} is outside data/'This repository’s live config uses the same mechanism with _docs/knowledge/ as
sqlite-only-under-knowledge.
Each observation is judged as one world with seven source names: target.path (the
repo-relative path), pre and post (the file’s text on the side the change carries —
a creation has no pre, a deletion no post), state ({ pre, post }, present only
on a modification), and changes (every path the observation changes — the one call on the
session surface, the whole staged set on the change-set surface), and command (the shell
call’s command line — present on a shell call only, and a shell call that changes no file
is one world of its own, so a declaration scoped on command sees it while one scoped on
target.path does not), and actor (the observation’s actor — described below). A
declaration that reads changes is written in changeSetDisciplines and compiled only where
the whole change set is observed, because one call never carries the other half of a pair.
This repository’s live config carries one — docs-stay-bilingual, an implies over the
.md/.ko.md pair, advised on the change-set surface when one side is staged without the
other. A declaration that needs a
file outside the target names it in a sources block, sources: { en: { file: 'locales/en.json' } }, and reads it as { op: 'source', of: 'en' }; the path is
repo-relative (no leading /, no .. segment) and the name may not be one of the seven. The
surface reads the file the way it observes the tree — the disk in a session, the index for a
staged commit, the <to> commit for a range — except that a named file the change itself
touches is read from the change’s post, so both surfaces judge the same text. A second
kind, sources: { spawns: { sidecar: true } }, names the session’s spawn-record channel
instead of a path — the subagent records the host keeps beside the transcript, supplied as
one JSON array; where the channel lives is the surface’s fact, so the value is the marker
true, and on the change-set surface (which has no session) the channel is always absent. A
third kind, sources: { session: { transcript: true } }, names the session’s own
conversation history — the user turns and tool calls the surface reads, handed to the
declaration as one snapshot whose entries carry their
observation ordinal; the history steps (toolUses, userTexts, first, ageMs) read it, and
agentType reads the parsed sidecar and requires is, the agent type to keep
({ op: 'agentType', is: 'tdd-test-writer' }); without it the step does not compile and
the entry lands as a skip registration. This repository’s live config carries one —
tests-before-implementation, an ordered over the ordinals of two subagent spawns, written
in sessionDisciplines. The seventh fixed name, actor, is the
observation’s actor — { agentType } inside a subagent, {} in the main session, absent
where the surface proves none (the change-set surface) — read as { op: 'source', of: 'actor' }
followed by select on agentType; it derives the actor axis the producer-owned and
actor-scope mechanisms require, and this repository’s live config carries one of each
(tests-are-the-writers, commits-come-from-the-main-session). A supply key must name
one of the seven fixed sources or one of the declaration’s own sources; any other key is
refused. A source the change does not carry is absent, and the declaration’s
supply block says what that means: error (the default) makes the call unjudgeable —
recorded blocked at either enforce level — pass leaves it unjudged, and empty reads
the absent side as an empty item list and judges on. empty is what lets an added-only
declaration see a creation as all-added and a deletion as adding nothing; it does not apply
to state, the paired source. A declaration comparing before with after therefore needs
supply: { state: pass } to let a file creation through.
A break is recorded like any other family’s, with one addition: the telemetry row carries a
fifth field naming the elements the relation failed on (at most eight per relate entry, with
the true count beside them). A skipped row uses the same field for a reason token instead —
no-observation (the surface has no channel for what the entry reads), config-fault (the
block could not be assembled), or supply-pass (the declaration’s own supply: pass let an
absent source through). Every declaration also names its mechanism — one of eighteen
catalogue names such as naming, companion, or pairing — and the validator refuses a
name whose shape the declaration does not match: the axes its sources derive (change for
the fixed names other than actor, actor for actor, world for a file or sidecar
source, history for a transcript source) and the relations it relates must fall inside
what that name admits. A block the compiler cannot resolve — a step name outside the
registry, an argument outside a step’s keys — becomes a skip registration that names its
location on stderr and routes nothing. A shell write into the declaration’s scope whose
result the judge can compute (a redirect, a heredoc, an append) is judged as the file change
it makes; one it cannot compute (sed -i, an opaque command) records skipped. The
declaration’s own witness block joins the
human’s witness as a second way to open a blocked verdict.
Adding a discipline is a data edit — no code, no plumbing. Custom judge bodies remain the escape layer for the few rules data cannot express.