polydeukes 0.0.1 → 0.4.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,262 @@
1
+ /**
2
+ * `pdks covenant check` — the assembled commit-surface judgment runner (ADAPTER-git §4.3).
3
+ *
4
+ * This is the commit-surface counterpart of the session hook's composition root: the one
5
+ * umbrella-owned place where the git adapter (staged-diff vocabulary), the covenant
6
+ * dispatcher, and the config loader meet. Assembly order mirrors the session hook —
7
+ * loadConfig → normalizeProtectedPaths → collect/translate → dispatchCovenants — and the
8
+ * judge bodies it spawns are the very same covenant dist executables, so a staged change
9
+ * receives the same verdict a session tool call would (AC-4 same-judge).
10
+ *
11
+ * Each staged change is dispatched as its own single-change input: one staged file is
12
+ * the commit surface's analogue of one session tool call, so telemetry stays N:N (AC-6)
13
+ * and `gain` reads a per-file subject rather than one opaque batch line.
14
+ *
15
+ * The valve is a TTY prompt (PRD §4.4 decision A): the injected `ttyPrompt` seam returns
16
+ * the line a human typed at the terminal, compared against the config witness token in
17
+ * FULL (COVENANT-15 — substring acceptance is forbidden). The seam's absence models a
18
+ * non-interactive environment (CI, an AI-spawned git commit): no prompt, no witness —
19
+ * the valve is structurally reachable only by a human at a terminal, which is the
20
+ * commit-surface translation of "only a human utterance opens the session valve". The
21
+ * answer is cached so one commit prompts at most once, and nothing is ever persisted —
22
+ * a state file would be an agent-forgeable surface (PRD §7).
23
+ *
24
+ * fail-closed: a missing/invalid config, an unbuilt judge body, or a collector failure
25
+ * exits 2 with one blocked record. The telemetry path is settled before the first failure
26
+ * branch can be taken (ADAPTER-git-b §4.1), so the record has somewhere to land even when
27
+ * the config that names its path never loaded. An empty staging area is an explicit pass
28
+ * (nothing to judge — the dispatcher precedent of zero matches, zero records).
29
+ */
30
+ import { existsSync } from 'node:fs';
31
+ import { createRequire } from 'node:module';
32
+ import { dirname, join, resolve } from 'node:path';
33
+ import { collectStagedChanges, covenantInputFromStagedChanges, resolveGitAdapterSettings, STAGED_DELETE, STAGED_WRITE, } from '@polydeukes/adapter-git';
34
+ import { appendRecordFailOpen, DEFAULT_TELEMETRY_LOG_PATH, normalizeProtectedPaths, } from '@polydeukes/core';
35
+ import { compileDisciplineRegistrations, dispatchCovenants, } from '@polydeukes/covenant';
36
+ import { loadConfig } from './load-config.js';
37
+ /**
38
+ * Build the witness predicate for the TTY valve, or undefined when no valve can exist
39
+ * (no witness configured, or no TTY seam — both leave the dispatcher with no way to open
40
+ * one at all). The valve IS the witness: the judge has already broken, and the human at
41
+ * the terminal supplies the pass condition themselves, sudo-style. The prompt fires
42
+ * lazily on the first registration that actually BROKE and names it from the dispatcher's
43
+ * context (COVENANT-17 §4.5) — the label and the MATCHED entry, the same subject the
44
+ * telemetry row carries, so screen and log never disagree. The human reads what broke,
45
+ * on what, and how far one answer reaches. The verdict is cached: one commit, at most
46
+ * one prompt, full-token equality only — and the token itself is never printed, or
47
+ * typing it from memory would become copying it off the screen.
48
+ *
49
+ * Both comparison sides are trimmed, mirroring the session valve: `ttlWitness` trims the
50
+ * config token at assembly precisely because config validation accepts a padded value,
51
+ * and it compares the utterance's first line trimmed — without the same normalisation
52
+ * here, one padded token would open the session surface and permanently shut this one
53
+ * (PR #41 review). The cache latches CLOSED before the seam is consulted: a throwing
54
+ * seam must not retry on the next broken registration, or the prompt's own commit-wide
55
+ * promise becomes a lie (AC §5.3 one commit, at most one prompt).
56
+ */
57
+ function ttyWitnessValve(witness, ttyPrompt) {
58
+ if (witness === undefined || ttyPrompt === undefined)
59
+ return undefined;
60
+ const token = witness.token.trim();
61
+ let verdict;
62
+ return (_input, _transcript, context) => {
63
+ if (verdict === undefined) {
64
+ const prompt = `covenant: '${context.label}' broke on the staged change matching '${context.subject}'.\n` +
65
+ 'answering opens the valve for the whole commit, not just this change.\n' +
66
+ 'type the agreed token in full to open it (enter to refuse): ';
67
+ verdict = false;
68
+ const answer = ttyPrompt(prompt);
69
+ verdict = answer !== null && answer.trim() === token;
70
+ }
71
+ return verdict;
72
+ };
73
+ }
74
+ /**
75
+ * Compose a judge body's module path and prove the file is there (CONFIG-06b §4.2).
76
+ * Spawning an absent module succeeds and its child exits 1 — the code a break verdict
77
+ * returns — so a judge that ran no line would arrive as a violation and, under `advise`,
78
+ * be waved through. Nothing downstream can separate the two (`translateExitCode` sees
79
+ * that number alone), so the proof happens here, before the spawn. Producing the path
80
+ * and proving it are one step on purpose: a path that skipped the proof cannot be
81
+ * constructed, and only the bodies this surface actually composes are proven.
82
+ */
83
+ function provenBodyPath(distDir, fileName) {
84
+ const modulePath = join(distDir, fileName);
85
+ if (!existsSync(modulePath)) {
86
+ throw new Error(`judge body ${modulePath} is missing — run 'pnpm build' to rebuild it`);
87
+ }
88
+ return modulePath;
89
+ }
90
+ /**
91
+ * One blocked record for a run that failed closed before any dispatch could judge.
92
+ *
93
+ * The write goes through `appendRecordFailOpen` rather than the mkdir-free `appendRecord`:
94
+ * a repository that has never been judged has no `.polydeukes/` directory — the shape
95
+ * `pdks init` leaves every consumer in — and the raw append would fail open on ENOENT,
96
+ * turning the very first fail-closed run into an unrecorded block. The wrapper carries both
97
+ * the parent-directory guarantee and the fail-open contract, so a telemetry failure still
98
+ * never softens the blocking exit. An undefined path is tolerated here because a non-string
99
+ * `repoRoot` leaves no root to write a row under.
100
+ */
101
+ function recordFailClosed(telemetryPath) {
102
+ if (telemetryPath === undefined)
103
+ return;
104
+ appendRecordFailOpen(telemetryPath, {
105
+ event: 'blocked',
106
+ label: 'covenant-check',
107
+ subject: '-',
108
+ });
109
+ }
110
+ /**
111
+ * Judge the staged changes of `repoRoot` exactly as the session surface would
112
+ * (ADAPTER-git §4.3). Async because the dispatcher spawns covenant bodies (CORE-01) —
113
+ * a synchronous runner would mean reimplementing the judge, which the single-dispatcher
114
+ * principle forbids.
115
+ */
116
+ export async function runCovenantCheck(spec) {
117
+ // Telemetry precedence settled BEFORE the failure branch (session-hook precedent): a config
118
+ // that never loads still has somewhere to write its one blocked row, and the config value
119
+ // replaces the provisional default once the load succeeds. The provisional default spells
120
+ // itself with the loader's own constant, so both terms converge on one source.
121
+ //
122
+ // Computed INSIDE the try even though it must run first, because `resolve` throws on a
123
+ // non-string repoRoot and that throw must not escape as a rejection. It leaves
124
+ // `telemetryPath` undefined, which the catch tolerates: there is no root to write a row
125
+ // under anyway.
126
+ //
127
+ // Both terms compose with `resolve`, never `join`: a relative repoRoot would leave the
128
+ // provisional path relative and the post-load one absolute, so a run whose config failed
129
+ // to load would write its row to a different file than the same repository's judgment
130
+ // rows — and a relative path is re-read against the cwd at append time, which need not
131
+ // be the cwd this ran under. The bin always passes `process.cwd()`, so the divergence is
132
+ // reachable only through this exported function, whose `repoRoot` promises no
133
+ // absoluteness (PR #57 review).
134
+ let telemetryPath;
135
+ let config;
136
+ try {
137
+ telemetryPath = spec.telemetryPath ?? resolve(spec.repoRoot, DEFAULT_TELEMETRY_LOG_PATH);
138
+ ({ config } = loadConfig(spec.repoRoot));
139
+ telemetryPath = spec.telemetryPath ?? resolve(spec.repoRoot, config.telemetry.logPath);
140
+ }
141
+ catch (error) {
142
+ process.stderr.write(`covenant check failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
143
+ recordFailClosed(telemetryPath);
144
+ return { exitCode: 2 };
145
+ }
146
+ let changes;
147
+ try {
148
+ changes = collectStagedChanges(spec.repoRoot);
149
+ }
150
+ catch (error) {
151
+ process.stderr.write(`covenant check failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
152
+ recordFailClosed(telemetryPath);
153
+ return { exitCode: 2 };
154
+ }
155
+ if (changes.length === 0) {
156
+ return { exitCode: 0 };
157
+ }
158
+ // Everything from here on is judgment assembly and dispatch: any throw (an unbuilt or
159
+ // unresolvable covenant dist, a registration-build failure) is unjudgeable and must
160
+ // both block AND leave one blocked record — the session hook's one-call-one-record
161
+ // invariant, which an unrecorded propagation to the bin's catch would narrow
162
+ // (review F5).
163
+ try {
164
+ // The adapter namespace validator throws on unknown levels/keys (CONFIG-06 §4.2) —
165
+ // resolved inside this try so a misconfiguration fails closed, never softens.
166
+ const { enforce, protectedPaths: gitAdditivePaths } = resolveGitAdapterSettings(config.adapters?.git);
167
+ // The commit surface judges the UNION of the common list and the git namespace's
168
+ // additive one (CONFIG-08 §4.2) — common first, so first-occurrence dedupe inside
169
+ // the one normalization pass is deterministic. The session hook reads the common
170
+ // list alone; that asymmetry is the contract, not an omission.
171
+ const protectedPaths = normalizeProtectedPaths({
172
+ protectedPaths: [...(config.protectedPaths ?? []), ...gitAdditivePaths],
173
+ });
174
+ // The judge bodies are the covenant package's dist executables — resolved through
175
+ // the real package (never a test alias), so the commit surface spawns the same
176
+ // judges the session hook does. An injected directory overrides that resolution:
177
+ // `createRequire` is real Node resolution and always lands on the real build, which
178
+ // no fixture can take a body away from.
179
+ const covenantDist = spec.covenantDist ?? dirname(createRequire(import.meta.url).resolve('@polydeukes/covenant'));
180
+ // Under advise the TTY valve is structurally absent (CONFIG-06 §4.6): a verdict
181
+ // already passes, so there is nothing to witness and the prompt must never fire.
182
+ const witness = enforce === 'advise' ? undefined : ttyWitnessValve(config.witness, spec.ttyPrompt);
183
+ const disciplines = config.disciplines ?? [];
184
+ const registrations = [
185
+ {
186
+ label: 'self-mod',
187
+ protectedPaths,
188
+ body: {
189
+ command: process.execPath,
190
+ args: [
191
+ provenBodyPath(covenantDist, 'self-mod-body.js'),
192
+ ...protectedPaths.flatMap((path) => ['--protected-path', path]),
193
+ ...[STAGED_WRITE, STAGED_DELETE].flatMap((tool) => ['--mutating-tool', tool]),
194
+ ],
195
+ },
196
+ witness,
197
+ },
198
+ // Command-family entries are excluded: the commit surface has no shell axis (a
199
+ // staged diff carries no commands), so registering them would be spawn waste by
200
+ // design (PRD §2) — a vacuous exclusion, hence recorded nowhere. Path and delta
201
+ // families judge the staged fileChanges as-is.
202
+ //
203
+ // Context-family entries are NOT filtered out any more. No transcript is injected
204
+ // here, so the compiler gives them skip registrations, and a skip records one
205
+ // `skipped` exactly when its trigger matches a staged change (COVENANT-13 §4.5).
206
+ // The commit surface stopped being a special case: an absent evidence channel gets
207
+ // the same disposition on both surfaces, and the scope gate comes free with the
208
+ // routing every registration already carries.
209
+ //
210
+ // The body path is passed as a thunk, so the proof fires only where the compiler
211
+ // actually composes a body (CONFIG-06b §4.2 corollary). Entry count cannot stand in
212
+ // for that: an entry may compile to a body-less skip — every `requirePrecedent` one
213
+ // does here, since this surface injects neither transcript nor evaluator — and the
214
+ // compiler appends the body-less `shell-unjudgeable` backstop even for zero entries,
215
+ // so gating the call itself would drop that record.
216
+ ...compileDisciplineRegistrations({
217
+ disciplines: disciplines.filter((entry) => entry.forbidCommand === undefined),
218
+ rootDir: spec.repoRoot,
219
+ bodyCommand: process.execPath,
220
+ bodyModulePath: () => provenBodyPath(covenantDist, 'discipline-body.js'),
221
+ shellTools: [],
222
+ commandArgs: [],
223
+ witness,
224
+ }),
225
+ ];
226
+ // The commit surface resolves the compiler through the installed package, so a
227
+ // workspace whose dist predates the lazy body-path convention hands back the thunk
228
+ // itself where a string belongs. `spawn` stringifies rather than rejects it, which
229
+ // would spawn the judge on the thunk's own source text and record the exit 1 as a
230
+ // verdict under a discipline's label — the confusion this ticket removes, arriving
231
+ // through the build-skew door. Assert the shape and let the fail-closed catch answer.
232
+ for (const registration of registrations) {
233
+ if (registration.body !== undefined && typeof registration.body.args?.[0] !== 'string') {
234
+ throw new Error(`covenant dist predates the lazy body-path convention (registration '${registration.label}') — run 'pnpm build'`);
235
+ }
236
+ }
237
+ let blocked = false;
238
+ let advisedCount = 0;
239
+ for (const change of changes) {
240
+ const input = covenantInputFromStagedChanges([change]);
241
+ const { exitCode, results } = await dispatchCovenants({
242
+ stdinPayload: JSON.stringify(input),
243
+ registrations,
244
+ telemetryPath,
245
+ dispatcherLabel: 'covenant-check',
246
+ enforce,
247
+ });
248
+ if (exitCode === 2)
249
+ blocked = true;
250
+ advisedCount += results.filter((result) => result.event === 'advised').length;
251
+ }
252
+ if (advisedCount > 0) {
253
+ process.stderr.write(`covenant advisory (enforce: advise): ${advisedCount} verdict(s) recorded, commit allowed\n`);
254
+ }
255
+ return { exitCode: blocked ? 2 : 0 };
256
+ }
257
+ catch (error) {
258
+ process.stderr.write(`covenant check failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
259
+ recordFailClosed(telemetryPath);
260
+ return { exitCode: 2 };
261
+ }
262
+ }
@@ -0,0 +1,95 @@
1
+ # Configuring Polydeukes
2
+
3
+ **English** · [한국어](./configuration.ko.md)
4
+
5
+ > Alpha. This guide covers the config surface as shipped today (schema v2, loader, and
6
+ > the four built-in discipline predicates). Fields and predicates will grow; what is
7
+ > written here is tested and enforced now.
8
+
9
+ `polydeukes.config.yaml` is the one file where a project declares its disciplines — the
10
+ promises the human and the AI partner both agree to be bound by. It is **data, not code**:
11
+ nothing in it can compute, so nothing in it can lie. The core validates it, the covenant
12
+ package enforces it, and every judgment it causes is measured.
13
+
14
+ This is the guide layer: the file itself, how discovery fails, the IDE wiring, and what
15
+ enforcement looks like. Every key, with its full rules and pitfalls, lives in
16
+ [the configuration reference](./reference/configuration.md).
17
+
18
+ ## The file
19
+
20
+ Put exactly one of these at the project root:
21
+
22
+ | Filename | Note |
23
+ |---|---|
24
+ | `polydeukes.config.yaml` | canonical |
25
+ | `polydeukes.config.yml` | accepted variant |
26
+ | `polydeukes.config.json` | accepted variant (read by the same parser — YAML is a JSON superset) |
27
+
28
+ Discovery is deliberately strict, and every failure refuses loudly instead of guessing:
29
+
30
+ - **No config found** → error naming all three candidate filenames. A missing config never
31
+ silently loads defaults — silent defaults would mean silently unprotected.
32
+ - **More than one found** → error naming the collisions. Ambiguity never picks a winner.
33
+ - **Parse error, or a custom YAML tag** → error naming the file. Custom tags are rejected
34
+ even though the parser cannot execute them — config data stays uncomputable by contract.
35
+ - **Schema violation** → error naming the key and the file. Unknown keys are rejected
36
+ wherever the core owns the vocabulary — the top level, and the fixed keys inside a
37
+ discipline entry — so `protectedPath:` for `protectedPaths:`, or `adaptors:` for
38
+ `adapters:`, is caught here. Two maps stay open, because their keys are your values
39
+ rather than the core's: language names under `languages`, and adapter names under
40
+ `adapters`. A misspelt adapter name is accepted and its block simply goes unread, which
41
+ leaves that adapter on its defaults — check the name against the adapter's own reference.
42
+ Inside a namespace the vocabulary belongs to that adapter: the core passes contents
43
+ through verbatim, and the adapter's own validator rejects what it does not recognise,
44
+ naming the full field path (see
45
+ [the `adapters` reference](./reference/configuration.md#adapters)).
46
+
47
+ ## IDE support
48
+
49
+ The JSON Schema gives autocompletion and validation in editors. It ships inside the
50
+ `polydeukes` package, so the line names a path into your own `node_modules`:
51
+
52
+ ```yaml
53
+ # yaml-language-server: $schema=node_modules/polydeukes/dist/schema/polydeukes.schema.json
54
+ ```
55
+
56
+ For a JSON config, use the standard top-level key instead. The loader accepts it and drops
57
+ it from the resolved config:
58
+
59
+ ```json
60
+ { "$schema": "node_modules/polydeukes/dist/schema/polydeukes.schema.json" }
61
+ ```
62
+
63
+ **The path is resolved against the directory your config sits in**, not against a project
64
+ root the editor infers. The spelling above is right when the two are the same place. When
65
+ they are not — a config in a monorepo sub-package whose dependencies installed at the
66
+ workspace root — count the levels up yourself:
67
+
68
+ ```yaml
69
+ # yaml-language-server: $schema=../../node_modules/polydeukes/dist/schema/polydeukes.schema.json
70
+ ```
71
+
72
+ `pdks init claude-code` writes the line only when the schema is where the plain spelling
73
+ names it. If the generated config has no such line, that is the case above, and the prefix
74
+ is yours to add — an unresolvable path costs you validation without reporting anything.
75
+
76
+ If you installed `@polydeukes/core` directly rather than the umbrella, name its own copy:
77
+
78
+ ```yaml
79
+ # yaml-language-server: $schema=node_modules/@polydeukes/core/schema/polydeukes.schema.json
80
+ ```
81
+
82
+ Every value here is a **file path**, not a module specifier: `$schema` is a static string an
83
+ editor reads, so no module resolver runs on it. Code that reads the schema at runtime uses
84
+ the package subpath `polydeukes/schema.json` instead.
85
+
86
+ ## What enforcement looks like
87
+
88
+ A violating tool call or shell command is **blocked (exit 2)** before it runs, with the
89
+ discipline's `id` in the telemetry record. The sanctioned valve is the witness — a human
90
+ supplying the pass condition on a judgment that actually blocked, recorded as
91
+ `witnessed` — never silent. On the commit surface under
92
+ `adapters.git.enforce: advise`, a verdict is recorded as `advised` and the commit
93
+ proceeds — a backstop that measures instead of blocking. A missing, ambiguous, or
94
+ invalid config blocks every call until it is fixed: the system fails closed, because a
95
+ dead gate that waves things through is the cheapest bypass of all.
@@ -0,0 +1,211 @@
1
+ # Installing Polydeukes
2
+
3
+ **English** · [한국어](./installation.ko.md)
4
+
5
+ > Alpha. This guide covers the install paths that ship today, and everything here is the
6
+ > measured behaviour of the published packages.
7
+
8
+ This is the getting-started layer: from an empty project to a first judged call.
9
+
10
+ One devDependency, one command per surface. The umbrella package `polydeukes` is the only
11
+ thing you install — it carries the core, the judge, and the adapters as its own
12
+ dependencies, and `pdks` is its CLI (an alias of `polydeukes`).
13
+
14
+ **Two surfaces ship, for two different situations — pick the one that matches how the
15
+ project is developed.** A project built alongside an AI partner in Claude Code wires the
16
+ **session surface**: a PreToolUse hook that judges every editing tool call and shell
17
+ command as it is declared. A project you develop yourself wires the **commit surface**: a
18
+ pre-commit hook that judges the staged diff, so the discipline you declared for yourself
19
+ is applied at the moment work becomes history. They enforce the same config vocabulary,
20
+ but they answer different situations — there is no general reason to wire both in one
21
+ project.
22
+
23
+ ## Prerequisites
24
+
25
+ - **Node.js ≥ 24** — the engines floor of every published package.
26
+ - **A package manager** — pnpm and npm both work; examples below use pnpm.
27
+ - **Claude Code** — only for the session surface. The commit surface needs no AI tool at
28
+ all: just git and a way to run a pre-commit hook.
29
+
30
+ ## Install
31
+
32
+ ```sh
33
+ pnpm add -D polydeukes
34
+ ```
35
+
36
+ (or `npm install --save-dev polydeukes`.)
37
+
38
+ This must be a real project dependency, not a one-off `npx` run — both surfaces load the
39
+ judge from your project's own installed package.
40
+
41
+ ## The session surface — developing with an AI partner
42
+
43
+ From the project root:
44
+
45
+ ```sh
46
+ pnpm exec pdks init claude-code
47
+ ```
48
+
49
+ The command installs into the directory it is invoked from, and it proves the `polydeukes`
50
+ package resolves there **before writing anything** — if it does not (say, the install step
51
+ was skipped), it prints the install command and exits 2 with zero files written, never a
52
+ half-wired tree.
53
+
54
+ Five artifacts, none ever overwritten. What exists is reported and kept — the hook, the
55
+ config, and the discipline file are left alone, the settings file is merged, and
56
+ `.gitignore` is only ever appended to — so re-running is always safe:
57
+
58
+ | Artifact | What it is |
59
+ |---|---|
60
+ | `.claude/hooks/covenant-pretooluse.mjs` | The hook — a thin delegator that loads the judge from the installed package. Upgrading the package upgrades the judge; this file never changes. |
61
+ | `.claude/settings.json` | The PreToolUse registration for editing tools and shell calls. **Merged, never replaced** — your other hooks and permissions stay. |
62
+ | `polydeukes.config.yaml` | The starter protection policy: a placeholder `languages` block, a minimum `protectedPaths` list, and the witness block. The comments in the file explain why each entry is there. |
63
+ | `.claude/rules/polydeukes.md` | A scoped discipline file telling your AI partner that `pdks docs` exists and which topic answers what. It carries `paths` frontmatter, so it loads when a Polydeukes path is in play rather than sitting in every session's context. |
64
+ | `.gitignore` | An appended ignore rule for `.polydeukes/`, with its comment line — telemetry is local observation data and never belongs in history. |
65
+
66
+ ## First edit — `languages`
67
+
68
+ The generated config ships a placeholder language profile, because the installer cannot
69
+ know your stack:
70
+
71
+ ```yaml
72
+ languages:
73
+ placeholder:
74
+ productionGlob: 'src/**'
75
+ testCmd: 'echo "set a verification command for {scope}"'
76
+ ```
77
+
78
+ Rename the key to your language, point `productionGlob` at your production sources, and put
79
+ your real verification command in `testCmd`. (On the commit-surface path you write this
80
+ block yourself as part of the config below.) The placeholder is valid as generated and no
81
+ judgment path reads these values yet, so it cannot produce a wrong verdict while it waits —
82
+ but `languages` is the schema's one required block, so *removing* it (or emptying it) makes
83
+ the config invalid, and an invalid config blocks every call. Edit it, don't delete it.
84
+
85
+ ## The commit surface — developing by yourself
86
+
87
+ This path is for applying your own discipline to your own commits — no AI tool involved.
88
+ It has no installer today; the wiring is two small manual steps.
89
+
90
+ **First, the config.** Create `polydeukes.config.yaml` at the project root (there is no
91
+ generator on this path — the file is yours from the first line):
92
+
93
+ ```yaml
94
+ languages:
95
+ typescript:
96
+ productionGlob: 'src/**'
97
+ testCmd: 'pnpm test'
98
+
99
+ # Judged at commit time: a staged change to these paths stops the commit
100
+ # until you answer the witness prompt in person.
101
+ protectedPaths:
102
+ - 'db/migrations'
103
+
104
+ witness:
105
+ token: 'pdks witness'
106
+ ttlMinutes: 10
107
+ ```
108
+
109
+ Add `.polydeukes/` to your `.gitignore` too — telemetry is local observation data.
110
+
111
+ **Then, the hook.** One command judges what is currently staged and exits 2 on a broken
112
+ covenant:
113
+
114
+ ```sh
115
+ pnpm exec pdks covenant check
116
+ ```
117
+
118
+ Register it as a pre-commit hook. With **lefthook**:
119
+
120
+ ```yaml
121
+ # lefthook.yml
122
+ pre-commit:
123
+ commands:
124
+ covenant:
125
+ priority: 1
126
+ interactive: true # keep the witness prompt visible — see below
127
+ run: ./node_modules/.bin/pdks covenant check
128
+ ```
129
+
130
+ With **husky**:
131
+
132
+ ```sh
133
+ # .husky/pre-commit
134
+ ./node_modules/.bin/pdks covenant check
135
+ ```
136
+
137
+ With plain **`.git/hooks`** (make it executable):
138
+
139
+ ```sh
140
+ #!/bin/sh
141
+ # .git/hooks/pre-commit
142
+ ./node_modules/.bin/pdks covenant check
143
+ ```
144
+
145
+ Three things to know about this surface:
146
+
147
+ - **The valve is a TTY prompt.** At the default `block` level, a commit that stages a
148
+ protected change stops at a prompt only a human at a terminal can answer. Configure your
149
+ hook runner so it does not swallow that prompt (lefthook needs `interactive: true`).
150
+ - **Two discipline families judge here.** A staged diff carries file changes and nothing
151
+ else, so protection lists and the delta and path families (`forbid`, `immutable`) judge
152
+ in full. A command-family entry (`forbidCommand`) has no command line to read in a
153
+ staged diff and is not assembled on this surface, and a context-family entry
154
+ (`requirePrecedent`) is recorded as `skipped` — declare those two where an AI partner's
155
+ session exists to be judged.
156
+ - **The commit surface has its own additive scope.** Paths that are fine to edit freely
157
+ but whose promotion into history deserves a judged checkpoint go under the adapter
158
+ namespace, judged on top of the shared list:
159
+
160
+ ```yaml
161
+ adapters:
162
+ git:
163
+ protectedPaths:
164
+ - 'src/policy'
165
+ ```
166
+
167
+ ## The witness valve
168
+
169
+ Both surfaces carry the same valve, spelled for their situation. It sits **after** the
170
+ verdict — only a judgment that actually blocked can be witnessed open — and every allowance
171
+ is recorded as `witnessed`, never silent.
172
+
173
+ ```yaml
174
+ witness:
175
+ token: 'pdks witness'
176
+ ttlMinutes: 10
177
+ ```
178
+
179
+ - **Session surface:** a human types the token so it stands alone on the first line of a
180
+ conversation message; the window holds for `ttlMinutes`, then blocking resumes. An agent
181
+ cannot open the valve for itself — only human-authored messages count.
182
+ - **Commit surface:** the blocked commit shows a TTY prompt, and typing the full token
183
+ there opens that one commit.
184
+
185
+ Change the token and window as you like — the token is not a secret; the defence is
186
+ provenance, not confidentiality. **Keep the block**: on the session surface the generated
187
+ protection list covers `.claude/hooks`, so without a valve the first blocked call would
188
+ freeze the project until a human edits the config from their own terminal.
189
+
190
+ ## Prove the gate is live
191
+
192
+ Prove it once on the surface you wired, then read the telemetry.
193
+
194
+ - **Session surface:** ask your agent to append a line to
195
+ `.claude/hooks/covenant-pretooluse.mjs` (a protected path). The call must come back
196
+ blocked.
197
+ - **Commit surface:** stage an edit to a path on your protection list and run
198
+ `git commit`. It must stop at the witness prompt (answer it, or abort with Ctrl-C).
199
+
200
+ ```sh
201
+ cat .polydeukes/roi.log
202
+ ```
203
+
204
+ Every judgment appends exactly one record — `passed`, `blocked`, `witnessed`, `advised`, or
205
+ `skipped` — so the block you just caused is the last line. A gate you have watched block
206
+ once is a gate you know is wired.
207
+
208
+ From here: [the configuration guide](./configuration.md) for the file and its wiring,
209
+ [the configuration reference](./reference/configuration.md) for every field and for
210
+ writing your own disciplines, and [troubleshooting](./troubleshooting.md) when something
211
+ blocks and you don't know why.
@@ -0,0 +1,82 @@
1
+ # `@polydeukes/adapter-claude-code`
2
+
3
+ **English** · [한국어](./adapter-claude-code.ko.md)
4
+
5
+ > **The session surface's translator** — PreToolUse payloads become the covenant input IR,
6
+ > with the file-change evidence and the transcript channel the judge reads.
7
+ >
8
+ > Alpha. A transitive dependency of the umbrella: you do not install it and you do not
9
+ > import it. The session surface reaches it through
10
+ > [`polydeukes/claude-code`](./polydeukes.md#subpaths).
11
+
12
+ ## What this package owns
13
+
14
+ The boundary where Claude Code's vocabulary is translated away. Agent and tool literals
15
+ live *here* by design, so that they never reach the core — which is what makes the core's
16
+ agent-neutrality a claim a test can check rather than a slogan.
17
+
18
+ | Unit | What it does |
19
+ |---|---|
20
+ | Payload up-translation | A raw PreToolUse payload becomes a `CovenantInput` |
21
+ | Virtual post-state | Computes what a file *would* contain after an edit applies, without touching disk |
22
+ | File-change evidence | Pairs the disk pre-state with the virtual post-state into union evidence |
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
+ | Telemetry wiring | Drives the full funnel so exactly one row lands per call |
26
+
27
+ 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.
29
+
30
+ ## Payload translation and the three axes
31
+
32
+ **Three axes reach the judge**, and they differ in what evidence they can carry.
33
+
34
+ | Axis | Carries | Consequence |
35
+ |---|---|---|
36
+ | Tool | A proven `fileChange` — the mutation target computed before the tool runs | Only the proven target is judged. A protected path inside an edit's *content* is a mention and passes |
37
+ | Shell | A command line whose target is often not computable before execution | Computable writes are judged like an edit; the rest is recorded rather than guessed |
38
+ | Transcript | The session's own record | Judged by whole-path equality, never as a protected ancestor |
39
+
40
+ Translation is fail-closed at every step. A `Task` call carrying a subagent type maps to a
41
+ 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
+
44
+ **Evidence is computed, never read back.** The virtual post-state applies `Edit`, `Write`,
45
+ and `MultiEdit` in memory — sequential multi-edit application included — so a content-aware
46
+ discipline judges the *proposed* result rather than the file as it currently is. An
47
+ unresolvable post-state yields no evidence at all, because the real tool would reject the
48
+ same edit, and evidence is never fabricated for a non-mutating call.
49
+
50
+ **The transcript admits only positively-identified human messages.** That is what makes the
51
+ witness valve human-only: an AI cannot synthesize its own witness. A read failure answers
52
+ `undefined` rather than an empty transcript — an empty session has said nothing yet and is
53
+ judged, an unreadable one is no evidence channel at all and is skipped. Either way the
54
+ valve turns off, never open.
55
+
56
+ **The precedent evaluator judges two keys.** `subagent` is exact spawn-kind equality, since
57
+ a kind is a value rather than a pattern; `tool` matches observed tool names as a regular
58
+ expression. Any key outside this vocabulary returns `undefined` — the handshake that tells
59
+ the compiler the evidence is unjudgeable, so the entry skips instead of judging on a guess.
60
+
61
+ ## Where the consumer touches it
62
+
63
+ - **The generated hook**, which loads this adapter through the umbrella's `claude-code`
64
+ subpath. Upgrading the package upgrades what runs; the hook file itself never changes.
65
+ - **`requirePrecedent` entries** using the `subagent` or `tool` evidence keys.
66
+
67
+ No import, and no configuration namespace of its own.
68
+
69
+ ## Declared limits
70
+
71
+ - **A child process's writes are outside observation.** This surface judges *declared tool
72
+ calls*. A command that spawns a process which then writes files — a test runner, a build
73
+ — is judged on the command, not on what the child did. The commit surface is the second
74
+ observation that covers the same ground for tracked files.
75
+ - **Evidence exists only where a post-state can be computed.** All four mutating tools
76
+ contribute one, notebooks included — a `NotebookEdit` yields cell-level `modify` evidence.
77
+ What yields nothing is a payload this adapter cannot resolve: an unreadable or unparseable
78
+ notebook, a cell it cannot name, an edit mode it does not know.
79
+ - **An evidence-free call falls back to the conservative judgment** — the call's arguments
80
+ are compared for a mention rather than a proven target.
81
+ - **Out-of-repository ancestors stay out of scope.** A path above the project root is not
82
+ observed here; the agent's own deny policy owns that ground.