polydeukes 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,338 @@
1
+ # Configuration reference
2
+
3
+ **English** · [한국어](./configuration.ko.md)
4
+
5
+ Every key of `polydeukes.config.yaml`, one section per key. The guide — what the file is,
6
+ how discovery fails, and the IDE wiring — is
7
+ [Configuring Polydeukes](../configuration.md), and what a verdict looks like when a
8
+ discipline fires is its
9
+ [What enforcement looks like](../configuration.md#what-enforcement-looks-like) section.
10
+
11
+ ## `languages`
12
+
13
+ Required. The language axis, first-class. Keys are your values (`typescript`, `python`, …) —
14
+ the core ships no language names and never interprets the command string.
15
+
16
+ ```yaml
17
+ languages:
18
+ typescript:
19
+ productionGlob: 'packages/*/src/**/*.ts' # what counts as production source
20
+ testCmd: 'pnpm --filter {scope} test' # {scope} is substituted at resolve time
21
+ ```
22
+
23
+ `testCmd` is a template string, not a function. Every `{scope}` occurrence is replaced;
24
+ all other braces (`${VAR}`, `{a,b}`, `awk '{print}'`) pass through untouched. A command
25
+ that ignores scope (`pnpm test`) is equally valid.
26
+
27
+ ## `protectedPaths`
28
+
29
+ Optional. Raw path patterns whose files the covenants protect from modification — by
30
+ editor tools and by shell commands alike (`sed -i`, `tee`, redirects, heredocs,
31
+ parent-directory moves). Entries are normalized (trimmed, deduplicated) at resolve time.
32
+ An empty-string entry is rejected at load time — it carries no path meaning.
33
+
34
+ ```yaml
35
+ protectedPaths:
36
+ - 'packages/core/src'
37
+ - '.claude/hooks'
38
+ ```
39
+
40
+ **The config file protects itself.** The discovered config file is automatically appended
41
+ to `protectedPaths` — an edit that would lower your own gates goes through the same judge
42
+ as everything else. If the file that declares the disciplines were not itself under the
43
+ disciplines, the whole chain would be decoration.
44
+
45
+ ## `adapters`
46
+
47
+ Optional. Adapter namespaces. One config file, one namespace per adapter: each key names an
48
+ adapter, and its value is that adapter's own settings object. The core validates the
49
+ container shape only — the keys and the contents belong to each adapter, which ships
50
+ its own validator for its own vocabulary. An unknown key *inside* a namespace is
51
+ rejected by that adapter's validator, with the full field path in the error.
52
+
53
+ ```yaml
54
+ adapters:
55
+ git:
56
+ enforce: advise
57
+ protectedPaths:
58
+ - 'packages/core/src'
59
+ ```
60
+
61
+ ### `adapters.git` — the git commit adapter
62
+
63
+ | Key | Values | Default | Meaning |
64
+ |---|---|---|---|
65
+ | `enforce` | `block` \| `advise` | `block` | Enforcement level of the commit surface |
66
+ | `protectedPaths` | string array | `[]` | Additive protection scope judged by the commit surface only |
67
+
68
+ - **`block`** — a staged change that breaks a covenant blocks the commit (exit 2). The
69
+ only way through is the witness valve: a human answering the TTY prompt with the full
70
+ token. The prompt names what it asks the human to witness — the broken registration,
71
+ the matched entry, and the fact that the one answer covers the whole commit. An absent
72
+ namespace, an absent `adapters` map, or an absent `enforce` key all mean `block` — not
73
+ writing the key selects the strictest level.
74
+ - **`advise`** — the commit surface becomes a backstop without a block: a verdict on a
75
+ staged change is recorded as an `advised` telemetry event and the commit proceeds
76
+ (exit 0) with one advisory line on stderr. No TTY prompt fires. Only the verdict is
77
+ relaxed — a run that cannot judge (missing or invalid config, an unresolvable judge
78
+ body) still fails closed at exit 2, at either level.
79
+
80
+ **`protectedPaths` here is an additive scope.** The commit surface judges the union of the
81
+ top-level `protectedPaths` and this list — concatenated (common first) and normalized as one,
82
+ so spelling and dedupe rules are identical for both. The session surface never reads it: the
83
+ list exists for paths whose edit is legitimate work during a session but must pass a judged
84
+ checkpoint when it is promoted into repository history — a judgment chain's own sources are
85
+ the canonical tenant. As the enforcement level is the observer's setting, so is the
86
+ additional scope. There is no subtractive vocabulary: a config line can widen a surface's
87
+ scope, never quietly strip one.
88
+
89
+ The session surface (the editor-time hook) has no level setting here. What it blocks is the
90
+ judging chain's own protection — `protectedPaths` mutations and mentions on the tool and
91
+ shell axes, the session transcript, an assembly that cannot judge (missing or invalid
92
+ config, unbuilt judge, unparseable payload, a routing that could not answer) — plus any
93
+ entry promoted with `enforce: block`. Every other discipline entry lands `advised` there.
94
+
95
+ **Context-family disciplines skip on the commit surface.** A commit has no session to look
96
+ at, so a `requirePrecedent` entry cannot be judged there — demanding evidence a commit
97
+ cannot carry would block every matching commit with no legitimate way through.
98
+
99
+ They are not filtered out, though. They assemble like any other discipline and become
100
+ *skip registrations*: routing intact, no judge body. When one matches a staged change it
101
+ records a `skipped` telemetry event and lets the commit proceed. The record carries the
102
+ entry's `id` and the change it would have judged, so a gate that did nothing says so in
103
+ the data — and it appears **only when the entry's scope actually matched**, so a commit
104
+ touching nothing the entry cares about records nothing at all.
105
+
106
+ This is the same disposition the session surface uses whenever it has no transcript to
107
+ read. One rule, both surfaces: evidence that cannot be evaluated is skipped and measured,
108
+ never blocked and never silent.
109
+
110
+ ## `telemetry`
111
+
112
+ Optional.
113
+
114
+ ```yaml
115
+ telemetry:
116
+ logPath: '.polydeukes/roi.log' # default when omitted; keep it gitignored
117
+ ```
118
+
119
+ Every judgment — passed, blocked, witnessed, advised, or skipped — appends one record.
120
+ Telemetry is fail-open by design: a logging failure never changes a verdict. The path
121
+ itself is still validated at load time — an empty or whitespace-only `logPath` is
122
+ rejected.
123
+
124
+ ## `witness`
125
+
126
+ Optional.
127
+
128
+ ```yaml
129
+ witness:
130
+ token: 'covenant witness' # the phrase a human types in the conversation
131
+ ttlMinutes: 10 # validity window, in minutes, from that message
132
+ ```
133
+
134
+ The values of the time-boxed human valve, consumed where the covenants are assembled.
135
+ The valve is sudo, not an exemption: the one property a deterministic gate can compute
136
+ about a judgment chain is "is an accountable human present, right now", and the witness
137
+ is that human supplying the pass condition in person. When a covenant blocks a
138
+ legitimate edit, a human types the agreed token into the conversation; blocked judgments
139
+ can be witnessed open for `ttlMinutes` from that message's timestamp, then blocking
140
+ resumes automatically. Both keys are required when the section is present: the token
141
+ must be non-empty after trimming, the window a finite number greater than zero.
142
+
143
+ **The valve stands after the verdict, never instead of it.** The judge body always runs.
144
+ A call that would have passed anyway never consults the valve, so an open window changes
145
+ nothing about clean work — and a `witnessed` telemetry row therefore always names a real
146
+ block a human answered for, never a ritual. Only a judgment that actually blocked can be
147
+ witnessed open.
148
+
149
+ **The token must stand alone on the message's first line.** Invoking the witness is
150
+ distinct from talking about it: a message that quotes, questions, or explains the token
151
+ mid-sentence — or wraps it in backticks — does not open the valve, while a first line
152
+ carrying the token alone does, with any following lines free for the work itself.
153
+
154
+ A message that invokes — the token alone on the first line, the rest free:
155
+
156
+ ```text
157
+ covenant witness
158
+
159
+ now fix the hook file
160
+ ```
161
+
162
+ A message that merely mentions — the valve stays shut:
163
+
164
+ ```text
165
+ so when does `covenant witness` expire?
166
+ ```
167
+
168
+ The token's value is free — any phrase works, and it is never checked for a prefix or a
169
+ command shape. Only its placement is constrained.
170
+
171
+ The token is not a secret — the defense is provenance, not secrecy. A witness counts only
172
+ when the token arrives in a message positively identified as human-typed in the session
173
+ transcript, so an AI that knows the token still cannot forge one. Witnessed judgments are
174
+ recorded as `witnessed`, never silent.
175
+
176
+ ## `disciplines`
177
+
178
+ Optional. Each entry is one discipline: a practice the team imposes on itself, declared as
179
+ data. An entry carries exactly **one** predicate (zero or two is rejected), an `id` (the
180
+ telemetry label), and optionally a `why` (the reason, which travels with the block message
181
+ the agent reads) plus, on a `forbid` or `requirePrecedent` entry, `in` (the file globs it
182
+ judges) and `except` (globs carved out of that scope).
183
+
184
+ **`draft` — an unpromoted entry.** The one shape that carries no predicate:
185
+ `{ id, why, draft: true }` and nothing else. A draft registers a practice as prose ahead of
186
+ its promotion — it makes no judgment and no telemetry record on either surface, and
187
+ `pdks explain` shows it as `unpromoted`. `why` is required here (the prose is the entry's
188
+ whole body), and the marker must be the literal `true` — a draft is declared, never
189
+ inferred, so an entry with neither a predicate nor `draft: true` is still a validation
190
+ error, and `draft: false` is rejected as dead data.
191
+
192
+ ```yaml
193
+ disciplines:
194
+ - id: 'bilingual-docs-sync'
195
+ why: 'en and ko doc mirrors must move together.'
196
+ draft: true
197
+ ```
198
+
199
+ A `why` is never judged — it changes no verdict. It is appended to the break message once a
200
+ verdict has blocked, so whoever reads the block gets the rationale in the same line instead
201
+ of having to open this file. A `why` spanning several lines is folded to spaces: the message
202
+ is one line.
203
+
204
+ **`enforce` — the entry's own level.** Optional on any judged entry: `block` or `advise`.
205
+ **Absent means `advise`.** Under `advise` a break is recorded as an `advised` telemetry
206
+ event and the call proceeds (exit 0), with the break message still written to stderr;
207
+ `block` is the promotion — it pins the entry at block. The entry's level composes with the
208
+ surface's (`adapters.git.enforce` on the commit surface; the session surface has none) and
209
+ the lenient side wins — an `advise` on either axis makes the entry advise, and an explicit
210
+ `block` never raises a surface the observer set to advise. An unjudgeable body (never
211
+ built, or one that cannot be loaded) still blocks whatever the level. A draft carries no
212
+ `enforce`; any
213
+ other value is rejected at load time. `pdks explain` prints the level an entry declares
214
+ (`enforce: block` or `enforce: advise`) on both surfaces and leaves an absent one unmarked;
215
+ the session header states the default.
216
+
217
+ ```yaml
218
+ - id: 'no-console-log'
219
+ why: 'console output belongs to the logger; measure the habit before blocking it.'
220
+ forbid: 'console\.log\('
221
+ enforce: advise
222
+ ```
223
+
224
+ **`forbid` — content delta.** Blocks an edit that *adds* a new match of the pattern.
225
+ Existing occurrences are forgiven: adopting a discipline never blocks a legacy codebase,
226
+ because the judgment direction is "what did this edit add", not "what does the file
227
+ contain".
228
+
229
+ ```yaml
230
+ disciplines:
231
+ - id: 'covenant-vocabulary'
232
+ why: 'control-framing vocabulary is banned in package sources.'
233
+ in:
234
+ - 'packages/*/src/**'
235
+ forbid: '\b(guard|harness|kb)\b'
236
+ ```
237
+
238
+ **`immutable` — path family.** Blocks modification of existing files that match; creating
239
+ new files is allowed.
240
+
241
+ ```yaml
242
+ - id: 'archived-records-stay-frozen'
243
+ why: 'an archive that can be edited is not an archive.'
244
+ immutable: 'records/archive/**'
245
+ ```
246
+
247
+ **`forbidCommand` — command family.** Blocks shell commands matching the pattern, even
248
+ when the command mentions no protected path. This is how gate-disarming commands are
249
+ caught. A multi-line command is judged twice over — the pattern is tested against each
250
+ line and against the whole string, so `^` means the start of a line while a pattern
251
+ spanning a line boundary still matches (the whole-content caution further down applies
252
+ to the delta and context families). An empty pattern is rejected at load time, here and
253
+ on `forbid` alike — it would match every command.
254
+
255
+ ```yaml
256
+ - id: 'hooks-stay-armed'
257
+ why: 'a command that disarms or reroutes the git gate is a gate bypass in itself.'
258
+ forbidCommand: 'LEFTHOOK=(0|false|no|off)\b|core\.hooksPath'
259
+ ```
260
+
261
+ **`requirePrecedent` — context family.** Blocks a change that arrives without a required
262
+ step having happened earlier in the session. The other three families all ask "is this
263
+ change itself bad"; this one asks something else. The change is legitimate — what is
264
+ missing is the procedure in front of it, so what gets judged is not the mutation but the
265
+ session history.
266
+
267
+ Evidence means an **execution**, not a request. A call the covenant blocked, one a human
268
+ refused, and one that simply failed all leave the same trace in a session, and none of
269
+ them is precedent — the transcript is read for what actually ran and reported success.
270
+ That is what keeps the cheapest way through the gate being the thing the discipline
271
+ asks for.
272
+
273
+ Two consequences are worth knowing before you write one. The outcome is read per command
274
+ LINE, so a chain where the required command ran but a later step failed does not count.
275
+ And the pattern is matched at the start of a simple command, so the same words in an
276
+ argument or a comment do not count either. **In both cases running the command on its own
277
+ opens the gate** — the block message says so.
278
+
279
+ ```yaml
280
+ - id: 'dependency-needs-npm-view'
281
+ why: 'a dependency version must be measured before it is written.'
282
+ in:
283
+ - 'package.json'
284
+ - 'packages/*/package.json'
285
+ when: '(^|\n)\s*"[^"]+"\s*:\s*"[~^]?\d[^"]*"'
286
+ requirePrecedent:
287
+ command: 'npm view '
288
+ ```
289
+
290
+ The evidence vocabulary is layered. `command` is the core's own key — a shell call is a surface
291
+ every agent shares — and the core validates it fully, rejecting an empty string or a pattern that
292
+ does not compile. It is matched **at the start of a simple command**, not anywhere in the command
293
+ line, so `echo "npm view yaml"` and a mention parked behind a `#` are not evidence while `cd pkg &&
294
+ npm view yaml` is. Every other key belongs to an adapter: the core checks the container only (a flat
295
+ object carrying exactly one evidence key) and passes the value through verbatim, and the adapter
296
+ that owns the word validates and judges it. The Claude Code adapter brings two: `subagent` (exact
297
+ match on a spawn kind) and `tool` (a regex over tool names) — so "query the docs tool before
298
+ touching this" is expressible today. Both follow the same execution rule as `command`. An evidence
299
+ key no assembled adapter recognizes cannot be judged, so the entry compiles to a skip registration:
300
+ routing stays, the body is dropped, assembly names the fault once on stderr, and every matching
301
+ change afterwards records `skipped` rather than a verdict. A typo therefore never passes itself off
302
+ as adapter vocabulary — but it does leave the discipline inert, and the `skipped` rows are where
303
+ that shows.
304
+
305
+ `when` (optional) is the trigger: an added-direction delta regex, combinable with
306
+ `requirePrecedent` and with nothing else. When it is absent, every change inside `in`
307
+ scope triggers the discipline. The two keys divide the work — `in` says which files are
308
+ watched, `when` says which change in them demands the precedent.
309
+
310
+ **A caution on line anchors.** These patterns are matched against the file's whole content
311
+ as a single string, and the config schema takes a regex string with no flags. `^` therefore
312
+ anchors to the start of the *file*, not the start of a line, so a line-shaped pattern
313
+ written with `^` matches only the first line and the discipline silently stops firing —
314
+ the regex still compiles, the judgment still runs, and the verdict is `passed`. Write
315
+ `(^|\n)` when you mean the start of a line. This is why the example above carries
316
+ `(^|\n)\s*"[^"]+"…` rather than `^\s*"[^"]+"…`.
317
+
318
+ **And a caution on match length.** The delta keys on the matched *text*: a change is only
319
+ seen as added when the matched string itself differs between the file's before and after.
320
+ A pattern that stops mid-value — say at the first digit of a version — produces the same
321
+ match text for `4.0.5` and `4.0.6`, so a version bump adds nothing to the delta and the
322
+ discipline silently passes. Make the pattern span the whole value that can change; the
323
+ example above runs through the closing quote (`\d[^"]*"`) for exactly this reason. Both
324
+ failure shapes are the same class: the regex compiles, the verdict says `passed`, and
325
+ nothing tells you the discipline is inert — so when you add an entry, measure it against
326
+ a real file and a realistic edit, not a one-line snippet.
327
+
328
+ The kind of change matters at the trigger. With `when` present, a deletion never triggers
329
+ — deleting adds no content. With `when` absent, deletion triggers like any other change in
330
+ scope, since the declared scope is the whole mutation.
331
+
332
+ **The cheap way through is the honest one.** Unlike the witness, this evidence lives on the
333
+ AI's own surface, so it is not forgery-proof. It does not need to be: the least effortful
334
+ way to open this gate is to actually call the tool, and that is exactly the behaviour the
335
+ discipline exists to induce.
336
+
337
+ Adding a discipline is a data edit — no code, no plumbing. Custom judge bodies remain the
338
+ escape layer for the few rules data cannot express.
@@ -29,8 +29,10 @@ can check. Every other package depends on this one; this one depends on none of
29
29
 
30
30
  ## The judged protocol
31
31
 
32
- This is the contract the shipped judge bodies speak: a body reads a `CovenantInput` from
33
- stdin and answers with an exit code. Every row in `.polydeukes/roi.log` traces back to one
32
+ This is the contract the shipped judges speak: a judge receives a `CovenantInput` — parsed
33
+ once from the stdin-JSON payload the surface hands the dispatcher — and answers with a
34
+ verdict the wrapper translates into an exit code. Every row in `.polydeukes/roi.log` traces
35
+ back to one
34
36
  of these verdicts, so this vocabulary is what a blocked row is written in.
35
37
 
36
38
  ```ts
@@ -83,10 +85,10 @@ attribution. Calls without evidence are skipped, never substituted for.
83
85
  Three places, all of them indirect.
84
86
 
85
87
  - **The config file.** Its schema is defined here. The vocabulary reference is
86
- [configuration.md](../configuration.md).
87
- - **The JSON Schema artifact** — `@polydeukes/core/schema.json`, an exports subpath. Under
88
- a default pnpm layout it does not resolve from a consumer's top-level `node_modules`,
89
- because the core arrives as a transitive dependency; the working spellings are in
88
+ [the configuration reference](./configuration.md).
89
+ - **The JSON Schema artifact** — `@polydeukes/core/schema.json`, an exports subpath, for a
90
+ project that installs this package directly. A consumer of the umbrella names the copy
91
+ bundled there instead; both spellings are in
90
92
  [configuration.md's IDE section](../configuration.md#ide-support).
91
93
  - **The protocol above** — reading a `blocked` row means reading the vocabulary a body
92
94
  answered in.
@@ -37,7 +37,7 @@ determines whether it can be judged on a given surface.
37
37
 
38
38
  `when` is a trigger, not a family: it narrows a `requirePrecedent` entry and combines with
39
39
  nothing else. The writing guide for these entries — the four predicate forms, the two
40
- pitfalls — is [configuration.md's `disciplines` section](../configuration.md#disciplines-optional).
40
+ pitfalls — is [the configuration reference's `disciplines` section](./configuration.md#disciplines).
41
41
 
42
42
  **Three meta-covenants** protect the judging chain. They are covenants like any other; the
43
43
  vocabulary below applies to them unchanged.
@@ -48,9 +48,9 @@ vocabulary below applies to them unchanged.
48
48
  | shell-mod | Shell | The same, through a command line. A command mentioning a protected path passes only if its leading word proves it read-only |
49
49
  | transcript-mod | Transcript | Writes to the live session transcript, judged by whole-path **equality** — never as a protected ancestor |
50
50
 
51
- **Five verdict words** are the telemetry contract. A row in `.polydeukes/roi.log` carries
52
- exactly one of them, and the CLI, the docs, and the tests use the same word for the same
53
- event. How to read a row is in
51
+ **Six words** are the telemetry contract — five verdicts and one observation. A row in
52
+ `.polydeukes/roi.log` carries exactly one of them, and the CLI, the docs, and the tests use
53
+ the same word for the same event. How to read a row is in
54
54
  [troubleshooting](../troubleshooting.md#reading-a-verdict).
55
55
 
56
56
  | Verdict | Means |
@@ -58,8 +58,16 @@ event. How to read a row is in
58
58
  | `passed` | The call was judged and upheld the covenant |
59
59
  | `blocked` | The call was judged and broke it |
60
60
  | `witnessed` | A **blocked** verdict a human opened in person. Never silent, never a clean call |
61
- | `advised` | The commit surface at `enforce: advise` recorded a break without stopping it |
61
+ | `advised` | A break recorded without stopping the call — the default for every discipline entry on both surfaces, and the commit surface's outcome under `adapters.git.enforce: advise` |
62
62
  | `skipped` | The call reached a registration that could not judge it. **Not a pass** — the recorded absence of a judgment |
63
+ | `unattributed` | A protected entry's on-disk state moved and no judgment row explains it. **Not a verdict** — no call is blocked or passed by it; the session surface writes it after comparing state against a stored baseline |
64
+
65
+ `unattributed` answers a question the other five cannot. They are all written by a judge
66
+ about a call it was handed, so a write that arrives without a declared call — through an
67
+ interpreter, a test runner's child process, a script that assembles the path from its own
68
+ arguments — leaves no row at all. The comparison observes the result rather than the
69
+ spelling, so it records that write after the fact. It never blocks: the write already
70
+ happened, and the comparison fails open on both sides of the verdict.
63
71
 
64
72
  ## Where the consumer touches it
65
73
 
@@ -21,23 +21,39 @@ you do not install and do not import.
21
21
 
22
22
  ## Subcommands
23
23
 
24
- The bin is `pdks`, with `polydeukes` as an alias. There are no flags and no options
25
- anywhere: two subcommands take an exact two-word form, and `docs` takes an optional topic.
24
+ The bin is `pdks`, with `polydeukes` as an alias. Every argument form is matched against a
25
+ finite table: `covenant check` takes an optional domain flag, `init claude-code` is exact,
26
+ `explain` takes one word, and `docs` takes an optional topic.
26
27
 
27
28
  ### `pdks covenant check`
28
29
 
29
- The commit-surface judgment runner, invoked from a pre-commit hook. It discovers the config
30
- at the working directory, collects the staging area through the git adapter, translates it
31
- into the covenant input IR, and dispatches it through the same judge bodies the session hook
32
- spawns.
30
+ `pdks covenant check [--worktree | --range <base>..<head>]` — the commit-surface judgment
31
+ runner. It discovers the config at the working directory,
32
+ collects one observation of the repository through the git adapter, translates it into the
33
+ covenant input IR, and dispatches it through the same in-process judges the session hook calls.
34
+ Which observation is the domain flag's choice — the same violation receives the same verdict
35
+ in all three:
36
+
37
+ | Form | Domain | `pre` → `post` |
38
+ |---|---|---|
39
+ | `pdks covenant check` | The staging area — what a pre-commit hook judges | HEAD blob → staged blob |
40
+ | `pdks covenant check --worktree` | The working tree, untracked (non-ignored) files included | HEAD blob → bytes on disk |
41
+ | `pdks covenant check --range <base>..<head>` | Two refs; `<base>...<head>` reads from their merge-base, the PR reading | base blob → head blob |
42
+
43
+ `--worktree` and `--range` are diagnostic calls — run them after a task, before a PR, or in
44
+ CI. Only the staged form is a gate, so only it can prompt for the witness token; the other
45
+ two report exit `2` without a prompt, since there is no commit for a human to open.
33
46
 
34
47
  | Situation | Result |
35
48
  |---|---|
36
- | Staged changes break nothing | exit `0` |
49
+ | The domain's changes break nothing | exit `0` |
37
50
  | A staged change breaks a covenant, `enforce: block`, config has a `witness` block | Prompts once on `/dev/tty` for the witness token; an unanswered or wrong answer exits `2` |
38
51
  | The same, with no `witness` block in the config | exit `2` with no prompt — the valve is built from that block, so without it nothing can open a block |
39
- | A staged change breaks a covenant, `enforce: advise` | One advisory line on stderr, exit `0`, recorded `advised` |
40
- | Empty staging area | exit `0` — an explicit pass, not a skipped run |
52
+ | A worktree or range change breaks a covenant, `enforce: block` | exit `2`, never a prompt |
53
+ | A change breaks a covenant, `enforce: advise` | One advisory line on stderr, exit `0`, recorded `advised` |
54
+ | Empty domain (nothing staged, a clean tree, identical refs) | exit `0` — an explicit pass, not a skipped run |
55
+ | A range naming a ref git cannot resolve, or two refs with no merge-base | exit `2`, one `blocked` row |
56
+ | `--range` without an argument, an argument without `..`, both flags at once, or an unknown flag | The usage line on stderr, exit `2` |
41
57
  | No config, more than one config, or an invalid one | exit `2` |
42
58
  | A judge body that cannot be loaded | exit `2` |
43
59
 
@@ -48,7 +64,7 @@ permanent condition of the commit surface, not a fault.
48
64
  ### `pdks init claude-code`
49
65
 
50
66
  The session-surface installer. It proves that `polydeukes` resolves from the directory it
51
- was invoked in **before writing anything**, then creates five artifacts:
67
+ was invoked in **before writing anything**, then creates six artifacts:
52
68
 
53
69
  | Artifact | Kind |
54
70
  |---|---|
@@ -56,6 +72,7 @@ was invoked in **before writing anything**, then creates five artifacts:
56
72
  | `.claude/settings.json` | Merged — the PreToolUse registration is added to whatever the file already carries |
57
73
  | `polydeukes.config.yaml` | Created — the starter policy, with a placeholder `languages` block |
58
74
  | `.claude/rules/polydeukes.md` | Created — tells the AI partner to ask [`pdks docs`](#pdks-docs-topic) instead of searching the web |
75
+ | `.claude/skills/discipline-draft/SKILL.md` | Created — the classification procedure: a described problem becomes a config entry (judged at advise, or `draft: true`), and advised rows are consulted in the telemetry log |
59
76
  | `.gitignore` | Appended — one line for `.polydeukes/` |
60
77
 
61
78
  Nothing existing is overwritten: an artifact already present is reported as skipped and left
@@ -78,10 +95,10 @@ package, so the answer comes from the installed version rather than from the net
78
95
  | Topic | Answers from |
79
96
  |---|---|
80
97
  | `install` | [installation](../installation.md), in full |
81
- | `config` | [configuration](../configuration.md) — the Reference section |
82
- | `discipline` | [configuration](../configuration.md) — the `disciplines` section |
98
+ | `config` | [the configuration reference](./configuration.md), in full |
99
+ | `discipline` | [the configuration reference](./configuration.md) — the `disciplines` section |
83
100
  | `covenant` | [configuration](../configuration.md) — What enforcement looks like |
84
- | `witness` | [configuration](../configuration.md)'s `witness` section, then [troubleshooting](../troubleshooting.md)'s valve section |
101
+ | `witness` | [the configuration reference](./configuration.md)'s `witness` section, then [troubleshooting](../troubleshooting.md)'s valve section |
85
102
 
86
103
  **Every failure leaves stdout at zero bytes.** A missing bundled document, a heading the
87
104
  document no longer carries, an unknown topic — each names what was missing on stderr and
@@ -96,11 +113,59 @@ the package.
96
113
  `pdks init claude-code` writes a discovery file that points an AI partner at this
97
114
  subcommand; see the artifact table above.
98
115
 
116
+ ### `pdks explain`
117
+
118
+ The assembly reader. It loads the config at the working directory, assembles both surfaces'
119
+ registration sets through the same functions the two judgment runners use, and prints them
120
+ without judging — no judge thunk is called, no telemetry row is written, no transcript is
121
+ read.
122
+
123
+ ```text
124
+ pdks explain — polydeukes.config.yaml
125
+
126
+ surface: session (claude-code hook)
127
+ registrations 23 · judged 11 · skip 9 · meta 3 · excluded 0 · draft 1
128
+ meta self-mod paths 13 (common; includes the config file itself)
129
+ judge covenant-vocabulary forbid · in packages/*/src/** · except … · why ✓
130
+ skip covenant-vocabulary a shell write in scope whose result this layer cannot compute
131
+ draft bilingual-docs-sync unpromoted — no judgment
132
+ ...
133
+ surface: commit (git pre-commit) · enforce: advise
134
+ registrations 10 · judged 3 · skip 6 · meta 1 · excluded 3 · draft 1
135
+ skip manifest-needs-npm-view no session transcript to read
136
+ excluded hooks-stay-armed forbidCommand — no shell axis on this surface
137
+ draft bilingual-docs-sync unpromoted — no judgment
138
+ ```
139
+
140
+ One line per registration, in the order the surface dispatches them. The kind column has
141
+ five words: `meta` (the registrations protecting the judging chain — `self-mod`,
142
+ `shell-mod`, and on the session surface `transcript-mod`), `judge` (an entry with a judge
143
+ body, with its family, routing scope, and whether it carries a `why`), `skip` (a registration
144
+ that records `skipped` instead of judging, with the reason the compiler gave — the reason
145
+ that otherwise reaches stderr only on a config fault), `excluded` (a `forbidCommand`
146
+ entry on the commit surface, which has no shell axis), and `draft` (an unpromoted
147
+ `draft: true` entry, shown on both surfaces since it belongs to neither). `registrations`
148
+ counts the first three; `excluded` and `draft` are tallied apart because neither ever
149
+ becomes a registration.
150
+ The commit surface's header also names its `adapters.git.enforce` level, since an advising
151
+ surface records the same table but blocks nothing.
152
+
153
+ The session surface is rendered as the hook sees it under a normal payload — with a
154
+ transcript present — so `transcript-mod` and the context family appear as they do in a
155
+ session; the commit surface shows the context family as skips, which is that surface's
156
+ permanent condition.
157
+
158
+ | Call | Result |
159
+ |---|---|
160
+ | `pdks explain` | Both surfaces on stdout, exit `0` |
161
+ | `pdks explain <anything>` | The usage line on stderr, exit `2` |
162
+ | no config, two configs, or an invalid one | `pdks explain: <reason>` on stderr, stdout at zero bytes, exit `2` |
163
+
99
164
  ### Any other argument form
100
165
 
101
- Anything that is not one of these forms writes
102
- `usage: pdks covenant check | pdks init claude-code | pdks docs [topic]` to stderr and
103
- exits `2`.
166
+ Anything that is not one of these forms writes the usage line —
167
+ `usage: pdks covenant check [--worktree | --range <base>..<head>] | pdks explain |
168
+ pdks init claude-code | pdks docs [topic]` — to stderr and exits `2`.
104
169
 
105
170
  ## Exit codes
106
171
 
@@ -109,16 +174,16 @@ Three codes exist, and they live at two layers. **What a consumer's hook observe
109
174
 
110
175
  | Code | Constant | Emitted by | Means |
111
176
  |---|---|---|---|
112
- | `0` | `EXIT_UPHOLD` | Judge body, wrapper, bin | The promise was upheld — the call or commit proceeds |
113
- | `1` | `EXIT_BREAK_NON_BLOCKING` | Judge body only | A break reported as a signal. The wrapper translates it — into `2` under `enforce: block`, into `0` + an `advised` row under `advise`. It never reaches the surface either way |
177
+ | `0` | `EXIT_UPHOLD` | Judge outcome, wrapper, bin | The promise was upheld — the call or commit proceeds |
178
+ | `1` | `EXIT_BREAK_NON_BLOCKING` | Judge outcome only | A break reported as a signal. The wrapper translates it — into `2` under `enforce: block`, into `0` + an `advised` row under `advise`. It never reaches the surface either way |
114
179
  | `2` | `EXIT_BREAK_BLOCKING` | Wrapper, bin, fail-closed paths | The call or commit is refused |
115
180
 
116
- The asymmetry is the protocol's responsibility boundary. A covenant body decides *whether* a
117
- promise was broken and says so with `0` or `1`; deciding what a break *costs* belongs to the
118
- wrapper, and that is the one place `enforce` is read. A body can therefore be run, tested, and
119
- reasoned about without knowing whether the surface it runs under blocks or advises. Only the
120
- verdict relaxes: every unjudgeable outcome — a body exit of `2` or higher, a signal death —
121
- stays `2` at either level.
181
+ The asymmetry is the protocol's responsibility boundary. A judge decides *whether* a
182
+ promise was broken and answers `0` or `1` in its outcome; deciding what a break *costs* belongs
183
+ to the wrapper, and that is the one place `enforce` is read. A judge can therefore be run,
184
+ tested, and reasoned about without knowing whether the surface it runs under blocks or advises.
185
+ Only the verdict relaxes: every unjudgeable outcome — an outcome of `2` or higher, a throw
186
+ from the judge — stays `2` at either level.
122
187
 
123
188
  **Everything unjudgeable resolves to `2`.** A missing config, an invalid one, an
124
189
  unparseable payload, a judge body that was never built — each fails closed. The one
@@ -156,15 +221,22 @@ a silently unprotected project.
156
221
  function runCovenantCheck(spec: CovenantCheckSpec): Promise<{ exitCode: 0 | 2 }>;
157
222
 
158
223
  type CovenantCheckSpec = {
159
- repoRoot: string; // config discovery and staged collection anchor here
224
+ repoRoot: string; // config discovery and collection anchor here
160
225
  telemetryPath?: string; // overrides the config's log path
161
226
  covenantDist?: string; // overrides the resolved judge directory
162
227
  ttyPrompt?: (prompt: string) => string | null; // the TTY valve seam
228
+ domain?: CheckDomain; // which observation to judge; absent = staged
163
229
  };
230
+
231
+ type CheckDomain =
232
+ | { kind: 'staged' }
233
+ | { kind: 'worktree' }
234
+ | { kind: 'range'; base: string; head: string; ancestry?: 'merge-base' };
164
235
  ```
165
236
 
166
237
  The commit surface's composition root — what [`pdks covenant
167
- check`](#pdks-covenant-check) runs.
238
+ check`](#pdks-covenant-check) runs. `ancestry: 'merge-base'` is the `<base>...<head>`
239
+ reading; the adapter resolves the merge-base.
168
240
 
169
241
  `ttyPrompt` absent means a non-TTY environment, and the valve then has no way to open — an
170
242
  agent-spawned commit and a CI run reach the same state. The valve is a human at a terminal
@@ -203,8 +275,13 @@ needs no second dependency.
203
275
  |---|---|
204
276
  | `polydeukes` | The barrel — `loadConfig`, `runCovenantCheck`, `runClaudeCodeHook`, their spec types, `ResolvedConfig` |
205
277
  | `polydeukes/claude-code` | `runClaudeCodeHook` and `ClaudeCodeHookSpec` alone |
278
+ | `polydeukes/schema.json` | The config JSON Schema, copied from the core at build time |
206
279
 
207
280
  The generated hook delegator imports the subpath, not the barrel. ESM imports are eager, so
208
281
  importing the barrel would load the commit-surface runner — and the git adapter behind it —
209
282
  on every session tool call that will never use them. The subpath is the session surface's
210
283
  own entry point, and the barrel is for programmatic consumers.
284
+
285
+ `polydeukes/schema.json` is for code that reads the schema. A `$schema` line names the file
286
+ path instead — an editor reads that string statically, so no module resolver runs on it. Both
287
+ spellings are in [configuration.md's IDE section](../configuration.md#ide-support).
@@ -6,6 +6,9 @@
6
6
  > proceed, and four things worth knowing when a judgment surprises you. Each entry is
7
7
  > symptom → cause → recovery.
8
8
 
9
+ This is the guide layer for recovery: the fail-closed states, reading verdicts, and the
10
+ witness valve.
11
+
9
12
  The one principle behind half of this page: **a gate that cannot judge blocks rather than
10
13
  guesses.** A missing config, an ambiguous config, an invalid config, an installer that
11
14
  cannot prove resolution, and a judge that cannot be loaded all fail closed, because a dead
@@ -92,7 +95,7 @@ resolves again.
92
95
  one record to the telemetry log (`.polydeukes/roi.log` by default, `telemetry.logPath` to
93
96
  move it).
94
97
 
95
- **Recovery.** Read the last lines and the five-word vocabulary:
98
+ **Recovery.** Read the last lines and the six-word vocabulary:
96
99
 
97
100
  | Word | Means |
98
101
  |---|---|
@@ -101,6 +104,11 @@ move it).
101
104
  | `witnessed` | A blocked verdict a human opened in person. Never silent. |
102
105
  | `advised` | Commit surface at `advise` level: a break recorded without stopping the commit. |
103
106
  | `skipped` | A registration matched but could not judge — **the recorded absence of a judgment, not a pass.** |
107
+ | `unattributed` | A protected entry changed on disk and no judgment explains it — **an observation, not a verdict.** Nothing was blocked; the write already happened. |
108
+
109
+ An `unattributed` row names the entry, not the file inside it. Rebuilding a protected `dist`
110
+ without a judged call producing one is expected — it says a write reached that entry outside
111
+ the session's view, which is exactly what the row is for.
104
112
 
105
113
  ## Opening a blocked call — the witness
106
114
 
@@ -145,7 +153,9 @@ assembles as a skip registration: routing intact, no judge body. When its scope
145
153
  staged change it records `skipped` with the entry's id and proceeds.
146
154
 
147
155
  **Recovery.** None needed — this is a declared condition of the surface, not a defect. The
148
- row is the point: a gate that did nothing says so in the data. A context-family entry is
156
+ row is the point: a gate that did nothing says so in the data. `pdks explain` shows the
157
+ same fact before any commit: every context entry appears under the commit surface as a
158
+ `skip` line carrying the reason. A context-family entry is
149
159
  really a session-surface tool — on a project that wires only the commit surface, such an
150
160
  entry only ever buys telemetry, so declare it where an AI partner's session exists to be
151
161
  judged. The row appears only when the entry's scope actually matched, so an unrelated