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.
- package/README.ko.md +11 -4
- package/README.md +21 -5
- package/dist/baseline.d.ts +82 -0
- package/dist/baseline.js +166 -0
- package/dist/bin.d.ts +5 -6
- package/dist/bin.js +92 -38
- package/dist/claude-code-hook.d.ts +40 -17
- package/dist/claude-code-hook.js +187 -175
- package/dist/claude-code.d.ts +6 -0
- package/dist/claude-code.js +6 -0
- package/dist/covenant-check.d.ts +50 -36
- package/dist/covenant-check.js +167 -193
- package/dist/covenant-module.d.ts +25 -0
- package/dist/covenant-module.js +42 -0
- package/dist/docs/configuration.md +17 -9
- package/dist/docs/installation.md +42 -12
- package/dist/docs/reference/adapter-claude-code.md +6 -4
- package/dist/docs/reference/adapter-git.md +23 -10
- package/dist/docs/reference/configuration.md +265 -103
- package/dist/docs/reference/core.md +15 -7
- package/dist/docs/reference/covenant.md +32 -24
- package/dist/docs/reference/polydeukes.md +132 -32
- package/dist/docs/troubleshooting.md +37 -8
- package/dist/docs-query.d.ts +10 -10
- package/dist/docs-query.js +12 -12
- package/dist/explain.d.ts +25 -0
- package/dist/explain.js +153 -0
- package/dist/index.d.ts +11 -16
- package/dist/index.js +10 -15
- package/dist/init-claude-code.d.ts +31 -18
- package/dist/init-claude-code.js +254 -40
- package/dist/init-grok.d.ts +51 -0
- package/dist/init-grok.js +242 -0
- package/dist/load-config.d.ts +17 -15
- package/dist/load-config.js +13 -12
- package/dist/pre-state-reader.d.ts +22 -0
- package/dist/pre-state-reader.js +32 -0
- package/dist/scaffold-project.d.ts +23 -14
- package/dist/scaffold-project.js +97 -32
- package/dist/schema/polydeukes.schema.json +54 -81
- 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
|
-
##
|
|
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
|
-
|
|
|
36
|
-
|
|
|
37
|
-
|
|
|
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
|
-
- **
|
|
80
|
-
and a commit has none, so a matching entry records `skipped`. A permanent
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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.
|
|
176
|
-
telemetry label), and optionally a `why` (the
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
new
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
change
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
refused, and one that simply failed
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
the
|
|
271
|
-
`
|
|
272
|
-
`
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
33
|
-
|
|
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.
|