polydeukes 0.4.0 → 0.6.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.
Files changed (41) hide show
  1. package/README.ko.md +11 -4
  2. package/README.md +21 -5
  3. package/dist/baseline.d.ts +82 -0
  4. package/dist/baseline.js +166 -0
  5. package/dist/bin.d.ts +5 -6
  6. package/dist/bin.js +92 -38
  7. package/dist/claude-code-hook.d.ts +40 -17
  8. package/dist/claude-code-hook.js +187 -175
  9. package/dist/claude-code.d.ts +6 -0
  10. package/dist/claude-code.js +6 -0
  11. package/dist/covenant-check.d.ts +50 -36
  12. package/dist/covenant-check.js +167 -193
  13. package/dist/covenant-module.d.ts +25 -0
  14. package/dist/covenant-module.js +42 -0
  15. package/dist/docs/configuration.md +17 -9
  16. package/dist/docs/installation.md +42 -12
  17. package/dist/docs/reference/adapter-claude-code.md +6 -4
  18. package/dist/docs/reference/adapter-git.md +23 -10
  19. package/dist/docs/reference/configuration.md +265 -103
  20. package/dist/docs/reference/core.md +15 -7
  21. package/dist/docs/reference/covenant.md +32 -24
  22. package/dist/docs/reference/polydeukes.md +132 -32
  23. package/dist/docs/troubleshooting.md +37 -8
  24. package/dist/docs-query.d.ts +10 -10
  25. package/dist/docs-query.js +12 -12
  26. package/dist/explain.d.ts +25 -0
  27. package/dist/explain.js +153 -0
  28. package/dist/index.d.ts +11 -16
  29. package/dist/index.js +10 -15
  30. package/dist/init-claude-code.d.ts +31 -18
  31. package/dist/init-claude-code.js +254 -40
  32. package/dist/init-grok.d.ts +51 -0
  33. package/dist/init-grok.js +242 -0
  34. package/dist/load-config.d.ts +17 -15
  35. package/dist/load-config.js +13 -12
  36. package/dist/pre-state-reader.d.ts +22 -0
  37. package/dist/pre-state-reader.js +32 -0
  38. package/dist/scaffold-project.d.ts +23 -14
  39. package/dist/scaffold-project.js +97 -32
  40. package/dist/schema/polydeukes.schema.json +54 -81
  41. package/package.json +7 -7
@@ -21,11 +21,12 @@ agent-neutrality a claim a test can check rather than a slogan.
21
21
  | Virtual post-state | Computes what a file *would* contain after an edit applies, without touching disk |
22
22
  | File-change evidence | Pairs the disk pre-state with the virtual post-state into union evidence |
23
23
  | Transcript provider | Turns a session JSONL file into a `CanonicalTranscript` |
24
- | Precedent evaluator | This adapter's own evidence vocabulary for the context family |
25
24
  | Telemetry wiring | Drives the full funnel so exactly one row lands per call |
26
25
 
27
26
  This package never imports the covenant package. The dispatch seam is *injected* by the
28
- umbrella, which keeps dependencies one-way, through the core alone.
27
+ umbrella, which keeps dependencies one-way, through the core alone. It names
28
+ `@polydeukes/core` as a `peerDependency`: the vocabulary is shared with the judge, not
29
+ installed a second time here.
29
30
 
30
31
  ## Payload translation and the three axes
31
32
 
@@ -39,7 +40,9 @@ umbrella, which keeps dependencies one-way, through the core alone.
39
40
 
40
41
  Translation is fail-closed at every step. A `Task` call carrying a subagent type maps to a
41
42
  spawn; a payload that cannot be classified is a translation *failure* that logs one
42
- `blocked` record and exits `2`, rather than degrading into a guess.
43
+ `blocked` record and exits `2`, rather than degrading into a guess. The envelope's top-level
44
+ `agent_type` becomes the IR's `actor` — `{ agentType }` inside a subagent, `{}` otherwise;
45
+ `tool_input` is never read for it, since that is the agent's own text.
43
46
 
44
47
  **Evidence is computed, never read back.** The virtual post-state applies `Edit`, `Write`,
45
48
  and `MultiEdit` in memory — sequential multi-edit application included — so a content-aware
@@ -62,7 +65,6 @@ the compiler the evidence is unjudgeable, so the entry skips instead of judging
62
65
 
63
66
  - **The generated hook**, which loads this adapter through the umbrella's `claude-code`
64
67
  subpath. Upgrading the package upgrades what runs; the hook file itself never changes.
65
- - **`requirePrecedent` entries** using the `subagent` or `tool` evidence keys.
66
68
 
67
69
  No import, and no configuration namespace of its own.
68
70
 
@@ -23,25 +23,38 @@ AI or human.
23
23
 
24
24
  This is a pure library. It knows the staged-diff shape and nothing about installation, hook
25
25
  runners, or valves — wiring it into a pre-commit hook is a deployment act that lives in the
26
- umbrella.
26
+ umbrella. It names `@polydeukes/core` as a `peerDependency`: the vocabulary is shared with
27
+ the judge, not installed a second time here.
27
28
 
28
- ## Staged collection and the `adapters.git` namespace
29
+ ## Collection and the `adapters.git` namespace
30
+
31
+ **Three collectors, one shape.** `collectStagedChanges`, `collectWorktreeChanges`, and
32
+ `collectRangeChanges({ repoRoot, range: '<base>..<head>' | '<base>...<head>' })` each return the same
33
+ `StagedChange[]`, so the translator and everything after it is one path.
34
+
35
+ | Collector | `pre` | `post` | Also |
36
+ |---|---|---|---|
37
+ | staged | HEAD blob | The **staged** blob — never the worktree, which may have diverged after `git add` | |
38
+ | worktree | HEAD blob | The bytes on disk | Untracked, non-ignored files join as `added`; a file missing from disk is `deleted`, whether HEAD held it or only the index did; an unreadable path (a dangling symlink) yields null content and is judged on its path |
39
+ | range | base blob | head blob | `...` resolves the base to `git merge-base`; a ref git cannot resolve, or two refs with no merge-base, throws |
29
40
 
30
41
  **Collection is deliberately narrow about what it trusts.**
31
42
 
32
43
  | Decision | Why |
33
44
  |---|---|
34
- | `--no-renames` forced on | A rename is judged as a deletion plus an addition. A `git mv` of a protected file must not slip through as one opaque rename entry |
35
- | `pre` from the HEAD blob, `post` from the **staged** blob | Never the worktree, which may have diverged after `git add` |
36
- | A binary blob yields null content | Rather than lossily decoded bytes |
37
- | The unborn first commit narrows to all-added | Rather than throwing |
45
+ | `--no-renames` forced on, in every collector | A rename is judged as a deletion plus an addition. A `git mv` of a protected file must not slip through as one opaque rename entry |
46
+ | A binary blob or file yields null content | Rather than lossily decoded bytes |
47
+ | The unborn first commit narrows to all-added | Rather than throwing — staged and worktree alike |
48
+ | A type change (`T`) keeps its `pre` side | A symlink replaced by a file is a modification, so a delta judgment still sees what was removed |
49
+ | Every listing ends with `--` | A branch that shares its name with a file is a ref, never an ambiguous argument |
38
50
 
39
51
  Translation produces one tool call per change, under the adapter-owned names `staged-write`
40
52
  and `staged-delete`. A deletion always carries its evidence. A write carries it unless the
41
53
  staged blob was binary — there is no text to compare, so the call arrives with no
42
54
  `fileChange` at all and is judged on its path alone, the same as any unproven call.
43
55
  **The session collections are honestly empty** — the commit surface has no session, and a
44
- key is never fabricated to look like one.
56
+ key is never fabricated to look like one. There is no `actor` either: the hook cannot tell a
57
+ human's `git commit` from an agent's, so it proves none.
45
58
 
46
59
  **The namespace is this adapter's own vocabulary.** The core validates only the container
47
60
  shape — one settings object per adapter — and passes the contents through verbatim, so the
@@ -76,9 +89,9 @@ No import.
76
89
 
77
90
  ## Declared limits
78
91
 
79
- - **The context family cannot be judged here.** `requirePrecedent` needs session history
80
- and a commit has none, so a matching entry records `skipped`. A permanent condition of
81
- this surface, not a fault in the entry.
92
+ - **A declaration that reads the session cannot be judged here.** A `precedent` needs
93
+ session history and a commit has none, so a matching entry records `skipped`. A permanent
94
+ condition of this surface, not a fault in the entry.
82
95
  - **A commit never shows a gitignored file.** Anything outside version control — a built
83
96
  `dist`, a generated hook script — is invisible to this surface by nature. That is why the
84
97
  session surface carries those paths on the common list instead.
@@ -86,18 +86,23 @@ the canonical tenant. As the enforcement level is the observer's setting, so is
86
86
  additional scope. There is no subtractive vocabulary: a config line can widen a surface's
87
87
  scope, never quietly strip one.
88
88
 
89
- The session surface (the editor-time hook) has no level setting here; it always blocks.
90
-
91
- **Context-family disciplines skip on the commit surface.** A commit has no session to look
92
- at, so a `requirePrecedent` entry cannot be judged there — demanding evidence a commit
93
- cannot carry would block every matching commit with no legitimate way through.
94
-
95
- They are not filtered out, though. They assemble like any other discipline and become
96
- *skip registrations*: routing intact, no judge body. When one matches a staged change it
97
- records a `skipped` telemetry event and lets the commit proceed. The record carries the
98
- entry's `id` and the change it would have judged, so a gate that did nothing says so in
99
- the data — and it appears **only when the entry's scope actually matched**, so a commit
100
- touching nothing the entry cares about records nothing at all.
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
+ **Declarations that read the session skip on the commit surface.** A commit has no session
96
+ to look at, so a declaration whose `sources` bind the transcript — a `precedent`,
97
+ `phase-order`, `turn-locality` or `stated-ground` entry — cannot be judged there; demanding
98
+ evidence a commit cannot carry would block every matching commit with no legitimate way
99
+ through. The declaration's own `supply: { session: 'pass' }` disposes of the absence: when
100
+ its scope matches a staged change it records a `skipped` telemetry event carrying the reason
101
+ token `supply-pass` and lets the commit proceed. The record carries the entry's `id` and the
102
+ change it would have judged, so a gate that did nothing says so in the data — and it appears
103
+ **only when the entry's scope actually matched**. A declaration scoped on the `command`
104
+ source records nothing at all there: a staged diff carries no command line, so no world it
105
+ observes is ever admitted.
101
106
 
102
107
  This is the same disposition the session surface uses whenever it has no transcript to
103
108
  read. One rule, both surfaces: evidence that cannot be evaluated is skipped and measured,
@@ -172,123 +177,280 @@ recorded as `witnessed`, never silent.
172
177
  ## `disciplines`
173
178
 
174
179
  Optional. Each entry is one discipline: a practice the team imposes on itself, declared as
175
- data. An entry carries exactly **one** predicate (zero or two is rejected), an `id` (the
176
- telemetry label), and optionally a `why` (the reason, kept next to the rule) plus, on a
177
- `forbid` or `requirePrecedent` entry, `in` (the file globs it judges) and `except` (globs
178
- carved out of that scope).
180
+ data. A judged entry carries a `declare` block — the one judged form, a declaration whose
181
+ `scope` lives inside the block — an `id` (the telemetry label), and optionally a `why` (the
182
+ reason, which travels with the block message the agent reads) and an `enforce` level. The
183
+ closed key set is `id` · `why` · `enforce` · `declare`; any other key is refused.
184
+
185
+ **`draft` — an unpromoted entry.** The one shape that carries no predicate:
186
+ `{ id, why, draft: true }` and nothing else. A draft registers a practice as prose ahead of
187
+ its promotion — it makes no judgment and no telemetry record on either surface, and
188
+ `pdks explain` shows it as `unpromoted`. `why` is required here (the prose is the entry's
189
+ whole body), and the marker must be the literal `true` — a draft is declared, never
190
+ inferred, so an entry with neither a predicate nor `draft: true` is still a validation
191
+ error, and `draft: false` is rejected as dead data.
192
+
193
+ ```yaml
194
+ disciplines:
195
+ - id: 'bilingual-docs-sync'
196
+ why: 'en and ko doc mirrors must move together.'
197
+ draft: true
198
+ ```
199
+
200
+ A `why` is never judged — it changes no verdict. It is appended to the break message once a
201
+ verdict has blocked, so whoever reads the block gets the rationale in the same line instead
202
+ of having to open this file. A `why` spanning several lines is folded to spaces: the message
203
+ is one line.
204
+
205
+ **`enforce` — the entry's own level.** Optional on any judged entry: `block` or `advise`.
206
+ **Absent means `advise`.** Under `advise` a break is recorded as an `advised` telemetry
207
+ event and the call proceeds (exit 0), with the break message still written to stderr;
208
+ `block` is the promotion — it pins the entry at block. The entry's level composes with the
209
+ surface's (`adapters.git.enforce` on the commit surface; the session surface has none) and
210
+ the lenient side wins — an `advise` on either axis makes the entry advise, and an explicit
211
+ `block` never raises a surface the observer set to advise. An unjudgeable body (never
212
+ built, or one that cannot be loaded) still blocks whatever the level. A draft carries no
213
+ `enforce`; any
214
+ other value is rejected at load time. `pdks explain` prints the level an entry declares
215
+ (`enforce: block` or `enforce: advise`) on both surfaces and leaves an absent one unmarked;
216
+ the session header states the default.
217
+
218
+ ```yaml
219
+ - id: 'hooks-stay-armed'
220
+ why: 'a command that disarms or reroutes the git gate is a gate bypass in itself.'
221
+ enforce: advise
222
+ declare:
223
+ mechanism: 'forbidden-command'
224
+ scope: { source: 'command' }
225
+ extract:
226
+ hits:
227
+ - { op: 'source', of: 'command' }
228
+ - { op: 'lines' }
229
+ - { op: 'matches', re: 'LEFTHOOK=(0|false|no|off)\b|core\.hooksPath' }
230
+ relate:
231
+ - { id: 'gates-armed', relation: { op: 'empty', of: 'hits' }, message: '{value}' }
232
+ ```
179
233
 
180
- **`forbid` — content delta.** Blocks an edit that *adds* a new match of the pattern.
181
- Existing occurrences are forgiven: adopting a discipline never blocks a legacy codebase,
182
- because the judgment direction is "what did this edit add", not "what does the file
183
- contain".
234
+ **Added-direction content is a declaration.** A promise about what an edit *adds* — a
235
+ banned word, a stray `.only`, a citation that resolves nowhere — is written as an
236
+ `added-only` declaration: `pre` and `post` are each cut into lines and keyed by the match,
237
+ `onlyIn` keeps what `post` has and `pre` lacks, and `empty` over that difference is the
238
+ verdict. Existing occurrences are forgiven, so adopting the discipline never blocks a legacy
239
+ codebase. `supply: empty` is what lets a file creation (no `pre`) count as all-added and a
240
+ deletion (no `post`) as adding nothing; the `scope` block replaces `in`/`except` with
241
+ regular expressions over the path.
184
242
 
185
243
  ```yaml
186
244
  disciplines:
187
245
  - id: 'covenant-vocabulary'
188
246
  why: 'control-framing vocabulary is banned in package sources.'
189
- in:
190
- - 'packages/*/src/**'
191
- forbid: '\b(guard|harness|kb)\b'
247
+ declare:
248
+ mechanism: 'added-only'
249
+ scope: { source: 'target.path', include: ['^packages/[^/]+/src/'] }
250
+ supply: { pre: 'empty', post: 'empty' }
251
+ extract:
252
+ before:
253
+ - { op: 'source', of: 'pre' }
254
+ - { op: 'lines' }
255
+ - { op: 'keyByPattern', re: '\b(guard|harness|kb)\b' }
256
+ after:
257
+ - { op: 'source', of: 'post' }
258
+ - { op: 'lines' }
259
+ - { op: 'keyByPattern', re: '\b(guard|harness|kb)\b' }
260
+ added:
261
+ - { op: 'onlyIn', of: 'after', notIn: 'before' }
262
+ relate:
263
+ - id: 'nothing-added'
264
+ relation: { op: 'empty', of: 'added' }
265
+ message: 'adds {key}: {value}'
192
266
  ```
193
267
 
194
- **`immutable` — path family.** Blocks modification of existing files that match; creating
195
- new files is allowed.
268
+ The key is the match text, so a line carrying a word the file already has anywhere is
269
+ forgiven, and a line carrying two new words surfaces the first one now and the second on the
270
+ next judgment.
271
+
272
+ **A frozen path is a declaration too.** A file that may be created once and never modified
273
+ or deleted: `pre` present means a modification, `post` absent means a deletion, and either
274
+ breaks.
196
275
 
197
276
  ```yaml
198
277
  - id: 'archived-records-stay-frozen'
199
278
  why: 'an archive that can be edited is not an archive.'
200
- immutable: 'records/archive/**'
279
+ declare:
280
+ mechanism: 'self-absolution-ban'
281
+ scope: { source: 'target.path', include: ['^records/archive/'] }
282
+ supply: { pre: 'empty', post: 'empty' }
283
+ extract:
284
+ prior: [{ op: 'source', of: 'pre' }]
285
+ here: [{ op: 'source', of: 'target.path' }]
286
+ after: [{ op: 'source', of: 'post' }]
287
+ deleted: [{ op: 'onlyIn', of: 'here', notIn: 'after' }]
288
+ touched: [{ op: 'union', of: ['prior', 'deleted'] }]
289
+ relate:
290
+ - { id: 'frozen', relation: { op: 'empty', of: 'touched' }, message: '{value} is frozen' }
201
291
  ```
202
292
 
203
- **`forbidCommand` — command family.** Blocks shell commands matching the pattern, even
204
- when the command mentions no protected path. This is how gate-disarming commands are
205
- caught. A multi-line command is judged twice over — the pattern is tested against each
206
- line and against the whole string, so `^` means the start of a line while a pattern
207
- spanning a line boundary still matches (the whole-content caution further down applies
208
- to the delta and context families). An empty pattern is rejected at load time, here and
209
- on `forbid` alike — it would match every command.
293
+ **A command line is a source.** On the session surface a shell call carries its command
294
+ line as the fixed source `command`, and a call that changes no file is still one
295
+ observation — it is judged as a world of its own, with subject `-`. A `forbidden-command`
296
+ declaration reads that source, cuts it into lines, keeps the lines a pattern matches, and
297
+ requires the result to be `empty`. It scopes on `command` so that only shell calls are
298
+ admitted: an Edit carries no command line, and a declaration reading a source its world
299
+ lacks is unjudgeable. A multi-line command is judged line by line, so `^` means the start
300
+ of a line; a pattern that would span a line boundary does not match.
210
301
 
211
302
  ```yaml
212
303
  - id: 'hooks-stay-armed'
213
304
  why: 'a command that disarms or reroutes the git gate is a gate bypass in itself.'
214
- forbidCommand: 'LEFTHOOK=(0|false|no|off)\b|core\.hooksPath'
305
+ declare:
306
+ mechanism: 'forbidden-command'
307
+ scope: { source: 'command' }
308
+ extract:
309
+ hits:
310
+ - { op: 'source', of: 'command' }
311
+ - { op: 'lines' }
312
+ - { op: 'matches', re: 'LEFTHOOK=(0|false|no|off)\b|core\.hooksPath' }
313
+ relate:
314
+ - { id: 'gates-armed', relation: { op: 'empty', of: 'hits' }, message: '{value}' }
215
315
  ```
216
316
 
217
- **`requirePrecedent` — context family.** Blocks a change that arrives without a required
218
- step having happened earlier in the session. The other three families all ask "is this
219
- change itself bad"; this one asks something else. The change is legitimate — what is
220
- missing is the procedure in front of it, so what gets judged is not the mutation but the
221
- session history.
222
-
223
- Evidence means an **execution**, not a request. A call the covenant blocked, one a human
224
- refused, and one that simply failed all leave the same trace in a session, and none of
225
- them is precedent — the transcript is read for what actually ran and reported success.
226
- That is what keeps the cheapest way through the gate being the thing the discipline
227
- asks for.
228
-
229
- Two consequences are worth knowing before you write one. The outcome is read per command
230
- LINE, so a chain where the required command ran but a later step failed does not count.
231
- And the pattern is matched at the start of a simple command, so the same words in an
232
- argument or a comment do not count either. **In both cases running the command on its own
233
- opens the gate** — the block message says so.
317
+ **A precedent is a declaration over the session.** Most declarations ask "is this change
318
+ itself bad"; a `precedent` asks whether a required step happened earlier in the session.
319
+ The change is legitimate — what is missing is the procedure in front of it, so what gets
320
+ judged is the session history: `sources: { session: { transcript: true } }` hands the
321
+ declaration the user turns and tool calls as one snapshot, `toolUses` picks the calls,
322
+ `filter` keeps the ones that ran and **succeeded**, `select` reaches the command line, and
323
+ `matches` finds the required one; `nonEmpty` is the verdict. A call the covenant blocked,
324
+ one a human refused, and one that simply failed are not precedent. The pattern is matched
325
+ anywhere in a command line — a line that merely mentions the command counts, a declared
326
+ limit. `supply: { session: 'pass' }` is what makes the commit surface record `skipped`
327
+ instead of blocking every matching commit.
234
328
 
235
329
  ```yaml
236
330
  - id: 'dependency-needs-npm-view'
237
331
  why: 'a dependency version must be measured before it is written.'
238
- in:
239
- - 'package.json'
240
- - 'packages/*/package.json'
241
- when: '(^|\n)\s*"[^"]+"\s*:\s*"[~^]?\d[^"]*"'
242
- requirePrecedent:
243
- command: 'npm view '
332
+ declare:
333
+ mechanism: 'precedent'
334
+ scope: { source: 'target.path', include: ['^(packages/[^/]+/)?package\.json$'] }
335
+ sources: { session: { transcript: true } }
336
+ supply: { session: 'pass' }
337
+ extract:
338
+ npmView:
339
+ - { op: 'source', of: 'session' }
340
+ - { op: 'toolUses', names: ['Bash'] }
341
+ - { op: 'filter', when: [{ field: 'succeeded', eq: true }] }
342
+ - { op: 'select', path: 'args.command' }
343
+ - { op: 'matches', re: '\bnpm view ' }
344
+ relate:
345
+ - { id: 'npm-view', relation: { op: 'nonEmpty', of: 'npmView' }, message: 'no successful npm view precedes this manifest edit' }
346
+ ```
347
+
348
+ A tool call is evidence the same way: `toolUses` without `names`, then `field name` and
349
+ `matches` over the tool's name, or `toolUses` with `subagentType` for a spawn of one agent
350
+ kind. The other history mechanisms read the same snapshot — `phase-order` relates two
351
+ spawn ordinals with `ordered`, `turn-locality` keeps the user turns inside a time window
352
+ (`userTexts → ageMs → filter lte`), and `stated-ground` requires a user turn matching a
353
+ pattern; the last two are usually scoped on `command`, so that only the shell call they
354
+ apply to is judged.
355
+
356
+ **A caution on line anchors.** A declaration's `lines` step splits the text first, so `^`
357
+ inside `keyByPattern` or `matches` after it is the start of a line. A pattern that stops
358
+ mid-value — say at the first digit of a version — keys `4.0.5` and `4.0.6` alike, so a bump
359
+ adds nothing to an added-only difference and the discipline silently passes: make the
360
+ pattern span the whole value that can change. Both failure shapes compile, run, and answer
361
+ `passed`, so measure a new entry against a real file and a realistic edit.
362
+
363
+ **The cheap way through is the honest one.** Unlike the witness, session evidence lives on
364
+ the AI's own surface, so it is not forgery-proof. It does not need to be: the least
365
+ effortful way to open this gate is to actually run the command, and that is exactly the
366
+ behaviour the discipline exists to induce.
367
+
368
+ **`declare` — declaration family.** One judgment written as data, in the algebra grammar
369
+ the core publishes as `algebra-declaration.schema.json`: `judge = relate ∘ extract`. The
370
+ block carries the declaration's `scope`, `sources`, `supply`, `extract`, `relate`, and optional
371
+ `witness`; the entry's `id` is the declaration's name, so the block never carries a
372
+ `discipline` key, and `in`/`except`/`when` are refused — the `scope` block is the scope.
373
+
374
+ ```yaml
375
+ - id: 'db-only-under-knowledge'
376
+ why: 'a *.db file may exist only under _docs/knowledge/'
377
+ declare:
378
+ mechanism: 'naming'
379
+ scope: { source: 'target.path', include: ['\.db$'] }
380
+ extract:
381
+ outside:
382
+ - { op: 'source', of: 'target.path' }
383
+ - { op: 'matches', re: '^(?!_docs/knowledge/)' }
384
+ relate:
385
+ - id: 'placed'
386
+ relation: { op: 'empty', of: 'outside' }
387
+ message: '{value} is outside _docs/knowledge/'
244
388
  ```
245
389
 
246
- The evidence vocabulary is layered. `command` is the core's own key — a shell call is a surface
247
- every agent shares — and the core validates it fully, rejecting an empty string or a pattern that
248
- does not compile. It is matched **at the start of a simple command**, not anywhere in the command
249
- line, so `echo "npm view yaml"` and a mention parked behind a `#` are not evidence while `cd pkg &&
250
- npm view yaml` is. Every other key belongs to an adapter: the core checks the container only (a flat
251
- object carrying exactly one evidence key) and passes the value through verbatim, and the adapter
252
- that owns the word validates and judges it. The Claude Code adapter brings two: `subagent` (exact
253
- match on a spawn kind) and `tool` (a regex over tool names) — so "query the docs tool before
254
- touching this" is expressible today. Both follow the same execution rule as `command`. An evidence
255
- key no assembled adapter recognizes cannot be judged, so the entry compiles to a skip registration:
256
- routing stays, the body is dropped, assembly names the fault once on stderr, and every matching
257
- change afterwards records `skipped` rather than a verdict. A typo therefore never passes itself off
258
- as adapter vocabulary — but it does leave the discipline inert, and the `skipped` rows are where
259
- that shows.
260
-
261
- `when` (optional) is the trigger: an added-direction delta regex, combinable with
262
- `requirePrecedent` and with nothing else. When it is absent, every change inside `in`
263
- scope triggers the discipline. The two keys divide the work — `in` says which files are
264
- watched, `when` says which change in them demands the precedent.
265
-
266
- **A caution on line anchors.** These patterns are matched against the file's whole content
267
- as a single string, and the config schema takes a regex string with no flags. `^` therefore
268
- anchors to the start of the *file*, not the start of a line, so a line-shaped pattern
269
- written with `^` matches only the first line and the discipline silently stops firing —
270
- the regex still compiles, the judgment still runs, and the verdict is `passed`. Write
271
- `(^|\n)` when you mean the start of a line. This is why the example above carries
272
- `(^|\n)\s*"[^"]+"…` rather than `^\s*"[^"]+"…`.
273
-
274
- **And a caution on match length.** The delta keys on the matched *text*: a change is only
275
- seen as added when the matched string itself differs between the file's before and after.
276
- A pattern that stops mid-value — say at the first digit of a version — produces the same
277
- match text for `4.0.5` and `4.0.6`, so a version bump adds nothing to the delta and the
278
- discipline silently passes. Make the pattern span the whole value that can change; the
279
- example above runs through the closing quote (`\d[^"]*"`) for exactly this reason. Both
280
- failure shapes are the same class: the regex compiles, the verdict says `passed`, and
281
- nothing tells you the discipline is inert — so when you add an entry, measure it against
282
- a real file and a realistic edit, not a one-line snippet.
283
-
284
- The kind of change matters at the trigger. With `when` present, a deletion never triggers
285
- — deleting adds no content. With `when` absent, deletion triggers like any other change in
286
- scope, since the declared scope is the whole mutation.
287
-
288
- **The cheap way through is the honest one.** Unlike the witness, this evidence lives on the
289
- AI's own surface, so it is not forgery-proof. It does not need to be: the least effortful
290
- way to open this gate is to actually call the tool, and that is exactly the behaviour the
291
- discipline exists to induce.
390
+ This repository's live config carries the same declaration as `sqlite-only-under-knowledge`.
391
+
392
+ Each observation is judged as one **world** with seven source names: `target.path` (the
393
+ repo-relative path), `pre` and `post` (the file's text on the side the change carries —
394
+ a creation has no `pre`, a deletion no `post`), `state` (`{ pre, post }`, present only
395
+ on a modification), and `changes` (every path the observation changes — the one call on the
396
+ session surface, the whole staged set on the commit surface), and `command` (the shell
397
+ call's command line — present on a shell call only, and a shell call that changes no file
398
+ is one world of its own, so a declaration scoped on `command` sees it while one scoped on
399
+ `target.path` does not), and `actor` (the observation's actor — described below). A
400
+ declaration that reads `changes` is judged only where the whole change set is observed: the
401
+ session surface records it `skipped`, the same disposition the commit surface gives a
402
+ declaration that reads the session, because one call can never carry the other half of a
403
+ pair. This repository's live config
404
+ carries one — `docs-stay-bilingual`, an `implies` over the `.md`/`.ko.md` pair, advised
405
+ on the commit surface when one side is staged without the other. A declaration that needs a
406
+ file outside the target names it in a `sources` block, `sources: { en: { file:
407
+ 'locales/en.json' } }`, and reads it as `{ op: 'source', of: 'en' }`; the path is
408
+ repo-relative (no leading `/`, no `..` segment) and the name may not be one of the seven. The
409
+ surface reads the file the way it observes the tree — the disk in a session, the index for a
410
+ staged commit, the `<to>` commit for a range — except that a named file the change itself
411
+ touches is read from the change's `post`, so both surfaces judge the same text. A second
412
+ kind, `sources: { spawns: { sidecar: true } }`, names the session's spawn-record channel
413
+ instead of a path — the subagent records the host keeps beside the transcript, supplied as
414
+ one JSON array; where the channel lives is the surface's fact, so the value is the marker
415
+ `true`, and on the commit surface (which has no session) the channel is always absent. A
416
+ third kind, `sources: { session: { transcript: true } }`, names the session's own
417
+ conversation history — the user turns and tool calls the surface reads, handed to the
418
+ declaration as one snapshot whose entries carry their
419
+ observation ordinal; the history steps (`toolUses`, `userTexts`, `first`, `ageMs`) read it, and
420
+ `agentType` reads the parsed sidecar. This repository's live config carries one —
421
+ `tests-before-implementation`, an `ordered` over the ordinals of two subagent spawns, which
422
+ the commit surface (no session) records `skipped`. The seventh fixed name, `actor`, is the
423
+ observation's actor — `{ agentType }` inside a subagent, `{}` in the main session, absent
424
+ where the surface proves none (the commit surface) — read as `{ op: 'source', of: 'actor' }`
425
+ followed by `select` on `agentType`; it derives the `actor` axis the `producer-owned` and
426
+ `actor-scope` mechanisms require, and this repository's live config carries one of each
427
+ (`tests-are-the-writers`, `commits-come-from-the-main-session`). A `supply` key must name
428
+ one of the seven fixed sources or one of the declaration's own `sources`; any other key is
429
+ refused. A source the change does not carry is absent, and the declaration's
430
+ `supply` block says what that means: `error` (the default) makes the call unjudgeable —
431
+ recorded `blocked` at either enforce level — `pass` leaves it unjudged, and `empty` reads
432
+ the absent side as an empty item list and judges on. `empty` is what lets an added-only
433
+ declaration see a creation as all-added and a deletion as adding nothing; it does not apply
434
+ to `state`, the paired source. A declaration comparing before with after therefore needs
435
+ `supply: { state: pass }` to let a file creation through.
436
+
437
+ A break is recorded like any other family's, with one addition: the telemetry row carries a
438
+ fifth field naming the elements the relation failed on (at most eight per relate entry, with
439
+ the true count beside them). A `skipped` row uses the same field for a reason token instead —
440
+ `no-observation` (the surface has no channel for what the entry reads), `config-fault` (the
441
+ block could not be assembled), or `supply-pass` (the declaration's own `supply: pass` let an
442
+ absent source through). Every declaration also names its `mechanism` — one of eighteen
443
+ catalogue names such as `naming`, `companion`, or `pairing` — and the validator refuses a
444
+ name whose shape the declaration does not match: the axes its sources derive (`change` for
445
+ the fixed names other than `actor`, `actor` for `actor`, `world` for a `file` or `sidecar`
446
+ source, `history` for a `transcript` source) and the relations it relates must fall inside
447
+ what that name admits. A block the compiler cannot resolve — a step name outside the
448
+ registry, an argument outside a step's keys — becomes a skip registration that names its
449
+ location on stderr and routes nothing. A shell write into the declaration's scope whose
450
+ result the judge can compute (a redirect, a heredoc, an append) is judged as the file change
451
+ it makes; one it cannot compute (`sed -i`, an opaque command) records `skipped`. The
452
+ declaration's own `witness` block joins the
453
+ human's witness as a second way to open a blocked verdict.
292
454
 
293
455
  Adding a discipline is a data edit — no code, no plumbing. Custom judge bodies remain the
294
456
  escape layer for the few rules data cannot express.
@@ -27,10 +27,19 @@ hand-rolled and the published JSON Schema is a sibling artifact the source never
27
27
  *values* supplied by configs and adapters, so the core's agent-neutrality is a claim a grep
28
28
  can check. Every other package depends on this one; this one depends on none of them.
29
29
 
30
+ **The judge and the two adapters name it as a `peerDependency`, not a dependency of their
31
+ own.** They share the vocabulary rather than each installing a copy — `SOURCE_KINDS` and
32
+ `parseInput` have to be one set of values for the validator and the engine to agree, and two
33
+ copies would disagree silently instead of failing at install time. The umbrella carries the
34
+ ordinary dependency that satisfies that peer, which is why a consumer still installs one
35
+ package and gets core transitively.
36
+
30
37
  ## The judged protocol
31
38
 
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
39
+ This is the contract the shipped judges speak: a judge receives a `CovenantInput` — parsed
40
+ once from the stdin-JSON payload the surface hands the dispatcher — and answers with a
41
+ verdict the wrapper translates into an exit code. Every row in `.polydeukes/roi.log` traces
42
+ back to one
34
43
  of these verdicts, so this vocabulary is what a blocked row is written in.
35
44
 
36
45
  ```ts
@@ -38,6 +47,7 @@ type CovenantInput = {
38
47
  toolCalls: { name: string; args?: Record<string, unknown>; fileChange?: FileChange }[];
39
48
  subagentSpawns: { kind: string }[];
40
49
  userMessages: { text: string }[];
50
+ actor?: { agentType?: string };
41
51
  };
42
52
 
43
53
  type FileChange =
@@ -53,7 +63,9 @@ fills into `name`; `kind` on a spawn is likewise a value. `FileChange` is a disc
53
63
  union so that a deletion is first-class evidence rather than an unrepresentable case, and
54
64
  impossible states — a deletion carrying resulting content, a creation carrying a baseline —
55
65
  cannot be written down. `delete.pre` is absent when the baseline was a binary blob, because
56
- a deletion needs no content to be judged.
66
+ a deletion needs no content to be judged. `actor` is the observation's actor as the host
67
+ envelope proves it — `agentType` inside a subagent, `{}` in the main session — and is absent
68
+ when the surface proves none; the judge never defaults it.
57
69
 
58
70
  **Evidence has exactly one home: the call it belongs to.** `fileChange` absent means *this
59
71
  call is unproven*, and no sibling call's evidence stands in for it.
@@ -100,10 +112,6 @@ Everything else here is reached through `polydeukes`.
100
112
  not check that a namespace *name* is one anybody implements, and it does not look inside
101
113
  the namespace at all. Unknown vocabulary inside `adapters.git` is rejected by the git
102
114
  adapter's own validator, at its own layer — not here.
103
- - **`requirePrecedent` evidence is layered the same way.** The core fully validates the
104
- `command` key, because a shell command is the surface where an agent crosses into the
105
- system. Every other key is validated for container shape alone — a flat object holding
106
- exactly one key — and its value passes through verbatim for the owning adapter to judge.
107
115
  - **The default transcript is a noop.** A consumer that injects no real transcript
108
116
  converges on "nothing happened", which is the safe direction for a valve: it never opens.
109
117
  Real transcripts live behind adapters.