polydeukes 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,58 +1,27 @@
1
1
  /**
2
- * `pdks covenant check` — the assembled commit-surface judgment runner (ADAPTER-git §4.3).
2
+ * `pdks covenant check` — the commit surface's composition root.
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).
4
+ * Assembly mirrors the session hook — loadConfig → normalizeProtectedPaths → collect →
5
+ * dispatchCovenants — and spawns the same covenant dist bodies, so a change receives the
6
+ * verdict a session tool call would. Each change is dispatched as its own input so
7
+ * telemetry stays one row per file. The witness valve is a `/dev/tty` prompt that only
8
+ * the staged domain assembles; the other domains open no commit.
10
9
  *
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).
10
+ * fail-closed: a missing config, an unbuilt body, or a collector failure exits 2 with one
11
+ * blocked record. An empty domain is an explicit pass with no records.
29
12
  */
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';
13
+ import { resolve } from 'node:path';
14
+ import { collectRangeChanges, collectStagedChanges, collectWorktreeChanges, covenantInputFromStagedChanges, resolveGitAdapterSettings, STAGED_DELETE, STAGED_WRITE, } from '@polydeukes/adapter-git';
34
15
  import { appendRecordFailOpen, DEFAULT_TELEMETRY_LOG_PATH, normalizeProtectedPaths, } from '@polydeukes/core';
35
- import { compileDisciplineRegistrations, dispatchCovenants, } from '@polydeukes/covenant';
16
+ import { loadCovenantModule, resolveCovenantDist } from './covenant-module.js';
36
17
  import { loadConfig } from './load-config.js';
37
18
  /**
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).
19
+ * The TTY witness predicate, or undefined when no valve can exist (no witness configured
20
+ * or no TTY seam). It fires on the first registration that broke, names it from the
21
+ * dispatcher's context, and caches the answer: one commit, at most one prompt, full-token
22
+ * equality. Both sides are trimmed like the session valve, since config validation accepts
23
+ * a padded token. The cache latches closed before the seam is consulted so a throwing seam
24
+ * never re-prompts.
56
25
  */
57
26
  function ttyWitnessValve(witness, ttyPrompt) {
58
27
  if (witness === undefined || ttyPrompt === undefined)
@@ -71,32 +40,11 @@ function ttyWitnessValve(witness, ttyPrompt) {
71
40
  return verdict;
72
41
  };
73
42
  }
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
43
  /**
91
44
  * 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.
45
+ * `appendRecordFailOpen` creates the missing `.polydeukes/` of a never-judged repository,
46
+ * and a telemetry failure never softens the exit. An undefined path (non-string
47
+ * `repoRoot`) leaves no root to write under.
100
48
  */
101
49
  function recordFailClosed(telemetryPath) {
102
50
  if (telemetryPath === undefined)
@@ -108,137 +56,108 @@ function recordFailClosed(telemetryPath) {
108
56
  });
109
57
  }
110
58
  /**
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.
59
+ * The commit surface's registration set — one assembly that the runner dispatches and
60
+ * `explain` renders.
115
61
  */
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).
62
+ export function assembleCommitRegistrations(spec) {
63
+ const { config, rootDir, covenant, witness } = spec;
64
+ const { protectedPaths: gitAdditivePaths } = resolveGitAdapterSettings(config.adapters?.git);
65
+ // Union of the common list and the git-additive one, common first so first-occurrence
66
+ // dedupe is deterministic. The session hook reads the common list alone.
67
+ const protectedPaths = normalizeProtectedPaths({
68
+ protectedPaths: [...(config.protectedPaths ?? []), ...gitAdditivePaths],
69
+ });
70
+ const disciplines = config.disciplines ?? [];
71
+ const registrations = [
72
+ covenant.selfModRegistration({
73
+ protectedPaths,
74
+ mutatingToolNames: [STAGED_WRITE, STAGED_DELETE],
75
+ witness,
76
+ }),
77
+ // No shell axis here, so command-family entries are left out. Context-family entries
78
+ // stay in: with no transcript the compiler gives them skip registrations, which record
79
+ // `skipped` on a match.
80
+ ...covenant.compileDisciplineRegistrations({
81
+ disciplines: disciplines.filter((entry) => entry.forbidCommand === undefined),
82
+ rootDir,
83
+ shellTools: [],
84
+ commandArgs: [],
85
+ witness,
86
+ }),
87
+ ];
88
+ return registrations;
89
+ }
90
+ /** One stage's failure disposition: the stderr line, the recorded row, and exit 2. */
91
+ function failClosed(telemetryPath, error) {
92
+ process.stderr.write(`covenant check failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
93
+ recordFailClosed(telemetryPath);
94
+ return { exitCode: 2 };
95
+ }
96
+ /**
97
+ * Settle the telemetry path and load the config once, or fail closed. The provisional
98
+ * path is settled before the load so a config that never loads still has somewhere to
99
+ * write its blocked row; both terms use `resolve` so a relative `repoRoot` cannot send
100
+ * them to different files. The provisional term sits inside the try because `resolve`
101
+ * throws on a non-string `repoRoot`.
102
+ */
103
+ function settleConfig(spec) {
134
104
  let telemetryPath;
135
- let config;
136
105
  try {
137
106
  telemetryPath = spec.telemetryPath ?? resolve(spec.repoRoot, DEFAULT_TELEMETRY_LOG_PATH);
138
- ({ config } = loadConfig(spec.repoRoot));
107
+ const { config } = loadConfig(spec.repoRoot);
139
108
  telemetryPath = spec.telemetryPath ?? resolve(spec.repoRoot, config.telemetry.logPath);
109
+ return { settled: true, telemetryPath, config };
140
110
  }
141
111
  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 };
112
+ return { settled: false, ...failClosed(telemetryPath, error) };
154
113
  }
155
- if (changes.length === 0) {
156
- return { exitCode: 0 };
114
+ }
115
+ /**
116
+ * Collect the changes of one domain. The three collectors return the same shape, so
117
+ * everything downstream of this dispatch is one path.
118
+ */
119
+ function collectDomain(repoRoot, domain) {
120
+ if (domain.kind === 'worktree')
121
+ return collectWorktreeChanges(repoRoot);
122
+ if (domain.kind === 'range') {
123
+ const separator = domain.ancestry === 'merge-base' ? '...' : '..';
124
+ return collectRangeChanges(repoRoot, `${domain.base}${separator}${domain.head}`);
157
125
  }
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).
126
+ return collectStagedChanges(repoRoot);
127
+ }
128
+ /**
129
+ * Assemble the registrations and dispatch every collected change. Any throw here (an
130
+ * unbuilt dist, a registration-build failure) is unjudgeable: block and leave one record.
131
+ */
132
+ async function judgeChanges(spec, domain, telemetryPath, config, changes) {
163
133
  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
- }
134
+ // Inside the try so an invalid adapter namespace fails closed.
135
+ const { enforce } = resolveGitAdapterSettings(config.adapters?.git);
136
+ // Real Node resolution of the covenant package, so the commit surface runs the same
137
+ // judges the session hook does; tests inject a directory instead. Awaited before any
138
+ // registration is composed, so a dist the barrel cannot load fails the run closed here
139
+ // rather than leaving a half-judged table behind.
140
+ const covenantDist = spec.covenantDist ?? resolveCovenantDist();
141
+ const covenant = await loadCovenantModule(covenantDist);
142
+ // No valve under advise (nothing to witness) and none outside `staged`.
143
+ const witness = enforce === 'advise' || domain.kind !== 'staged'
144
+ ? undefined
145
+ : ttyWitnessValve(config.witness, spec.ttyPrompt);
237
146
  let blocked = false;
238
147
  let advisedCount = 0;
148
+ // Assembled ONCE for the run, not per change: a judge takes its call set as an argument,
149
+ // so the table is payload-free. Recompiling per file would repeat every compile-time
150
+ // side effect — the stderr line a config-faulted discipline names itself with would
151
+ // print once per staged file rather than once.
152
+ const registrations = assembleCommitRegistrations({
153
+ config,
154
+ rootDir: spec.repoRoot,
155
+ covenant,
156
+ witness,
157
+ });
239
158
  for (const change of changes) {
240
159
  const input = covenantInputFromStagedChanges([change]);
241
- const { exitCode, results } = await dispatchCovenants({
160
+ const { exitCode, results } = await covenant.dispatchCovenants({
242
161
  stdinPayload: JSON.stringify(input),
243
162
  registrations,
244
163
  telemetryPath,
@@ -249,14 +168,37 @@ export async function runCovenantCheck(spec) {
249
168
  blocked = true;
250
169
  advisedCount += results.filter((result) => result.event === 'advised').length;
251
170
  }
171
+ // Names no level: surface-level and entry-level advice mix in one run, so the commit's
172
+ // fate is read from the run.
252
173
  if (advisedCount > 0) {
253
- process.stderr.write(`covenant advisory (enforce: advise): ${advisedCount} verdict(s) recorded, commit allowed\n`);
174
+ const outcome = blocked ? 'commit blocked by another verdict' : 'commit allowed';
175
+ process.stderr.write(`covenant advisory: ${advisedCount} verdict(s) recorded as advised, ${outcome}\n`);
254
176
  }
255
177
  return { exitCode: blocked ? 2 : 0 };
256
178
  }
257
179
  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 };
180
+ return failClosed(telemetryPath, error);
261
181
  }
262
182
  }
183
+ /**
184
+ * Judge one observation of `repoRoot` exactly as the session surface would — the staged
185
+ * diff by default, the working tree or a ref range on request. Async because the dispatcher
186
+ * spawns covenant bodies. An empty domain is an explicit pass: nothing to judge, no records.
187
+ */
188
+ export async function runCovenantCheck(spec) {
189
+ const settlement = settleConfig(spec);
190
+ if (!settlement.settled)
191
+ return { exitCode: settlement.exitCode };
192
+ const { telemetryPath, config } = settlement;
193
+ const domain = spec.domain ?? { kind: 'staged' };
194
+ let changes;
195
+ try {
196
+ changes = collectDomain(spec.repoRoot, domain);
197
+ }
198
+ catch (error) {
199
+ return failClosed(telemetryPath, error);
200
+ }
201
+ if (changes.length === 0)
202
+ return { exitCode: 0 };
203
+ return judgeChanges(spec, domain, telemetryPath, config, changes);
204
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The covenant package as a resolved artifact — the existence proof both composition roots
3
+ * share.
4
+ *
5
+ * What the roots prove is the package IMPORT itself. The barrel is eager: a dist missing one
6
+ * of the modules it references throws on import, before any assembly can compose a
7
+ * registration, and the surface's own fail-closed catch records that as one `blocked` row. A
8
+ * partially loaded judge set has no representation here — an ESM import either fully succeeds
9
+ * or throws.
10
+ *
11
+ * The `covenantDist` seam selects WHICH dist is imported, so a fixture can inject a gutted
12
+ * mirror where real Node resolution would always land on the healthy build.
13
+ */
14
+ import type * as covenant from '@polydeukes/covenant';
15
+ /** The covenant surface both roots assemble against. */
16
+ export type CovenantModule = typeof covenant;
17
+ /** Where real Node resolution puts the covenant package's built barrel. */
18
+ export declare function resolveCovenantDist(): string;
19
+ /**
20
+ * Import the covenant barrel from `distDir`, naming the recovery command when it will not
21
+ * load. The message carries the loader's own text, which names the module that is missing;
22
+ * a reader locked out by an unbuilt or half-built dist needs both that name and the one
23
+ * command that fixes it.
24
+ */
25
+ export declare function loadCovenantModule(distDir: string): Promise<CovenantModule>;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The covenant package as a resolved artifact — the existence proof both composition roots
3
+ * share.
4
+ *
5
+ * What the roots prove is the package IMPORT itself. The barrel is eager: a dist missing one
6
+ * of the modules it references throws on import, before any assembly can compose a
7
+ * registration, and the surface's own fail-closed catch records that as one `blocked` row. A
8
+ * partially loaded judge set has no representation here — an ESM import either fully succeeds
9
+ * or throws.
10
+ *
11
+ * The `covenantDist` seam selects WHICH dist is imported, so a fixture can inject a gutted
12
+ * mirror where real Node resolution would always land on the healthy build.
13
+ */
14
+ import { createRequire } from 'node:module';
15
+ import { join } from 'node:path';
16
+ import { pathToFileURL } from 'node:url';
17
+ /** Where real Node resolution puts the covenant package's built barrel. */
18
+ export function resolveCovenantDist() {
19
+ return join(createRequire(import.meta.url).resolve('@polydeukes/covenant'), '..');
20
+ }
21
+ /**
22
+ * Import the covenant barrel from `distDir`, naming the recovery command when it will not
23
+ * load. The message carries the loader's own text, which names the module that is missing;
24
+ * a reader locked out by an unbuilt or half-built dist needs both that name and the one
25
+ * command that fixes it.
26
+ */
27
+ export async function loadCovenantModule(distDir) {
28
+ try {
29
+ return (await import(pathToFileURL(join(distDir, 'index.js')).href));
30
+ }
31
+ catch (error) {
32
+ throw new Error(`the covenant judges could not be loaded from ${distDir} — run 'pnpm build' to rebuild them: ${error instanceof Error ? error.message : String(error)}`);
33
+ }
34
+ }
@@ -85,11 +85,19 @@ the package subpath `polydeukes/schema.json` instead.
85
85
 
86
86
  ## What enforcement looks like
87
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.
88
+ A `disciplines:` entry lands at **advise** by default: a break is recorded as `advised`
89
+ with the discipline's `id` in the telemetry record, the break message with its `why` goes
90
+ to stderr, and the call proceeds (exit 0) — the judgment measures instead of stopping.
91
+ Writing `enforce: block` on an entry is the promotion: that entry then **blocks (exit 2)**
92
+ before the call runs. The sanctioned valve on a block is the witness — a human supplying
93
+ the pass condition on a judgment that actually blocked, recorded as `witnessed` — never
94
+ silent.
95
+
96
+ What blocks without being asked is the judging chain's own protection, a finite list: the
97
+ `protectedPaths` entries (tool-axis and shell-axis mutations, and mentions without a
98
+ read-only head), the session transcript, and the assembly itself — a missing, ambiguous, or
99
+ invalid config, an unbuilt judge, an unparseable payload, or a routing that could not
100
+ answer. At either level the system fails closed on these, because a dead gate that waves
101
+ things through is the cheapest bypass of all. On the commit surface `adapters.git.enforce: advise` relaxes
102
+ the protected-path verdicts to `advised` as well — it is the observer's setting — while an
103
+ assembly that cannot judge still fails closed.
@@ -51,8 +51,8 @@ package resolves there **before writing anything** — if it does not (say, the
51
51
  was skipped), it prints the install command and exits 2 with zero files written, never a
52
52
  half-wired tree.
53
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
54
+ Six artifacts, none ever overwritten. What exists is reported and kept — the hook, the
55
+ config, and the discipline files are left alone, the settings file is merged, and
56
56
  `.gitignore` is only ever appended to — so re-running is always safe:
57
57
 
58
58
  | Artifact | What it is |
@@ -61,6 +61,7 @@ config, and the discipline file are left alone, the settings file is merged, and
61
61
  | `.claude/settings.json` | The PreToolUse registration for editing tools and shell calls. **Merged, never replaced** — your other hooks and permissions stay. |
62
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
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
+ | `.claude/skills/discipline-draft/SKILL.md` | The classification procedure. Describe a recurring problem to your AI partner and it lands as a config entry — judged at advise when a current family can express it, a `draft: true` entry otherwise — and the same file tells the agent to consult `advised` rows in the telemetry log at task boundaries. |
64
65
  | `.gitignore` | An appended ignore rule for `.polydeukes/`, with its comment line — telemetry is local observation data and never belongs in history. |
65
66
 
66
67
  ## First edit — `languages`
@@ -25,16 +25,27 @@ This is a pure library. It knows the staged-diff shape and nothing about install
25
25
  runners, or valves — wiring it into a pre-commit hook is a deployment act that lives in the
26
26
  umbrella.
27
27
 
28
- ## Staged collection and the `adapters.git` namespace
28
+ ## Collection and the `adapters.git` namespace
29
+
30
+ **Three collectors, one shape.** `collectStagedChanges`, `collectWorktreeChanges`, and
31
+ `collectRangeChanges(repoRoot, '<base>..<head>' | '<base>...<head>')` each return the same
32
+ `StagedChange[]`, so the translator and everything after it is one path.
33
+
34
+ | Collector | `pre` | `post` | Also |
35
+ |---|---|---|---|
36
+ | staged | HEAD blob | The **staged** blob — never the worktree, which may have diverged after `git add` | |
37
+ | 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 |
38
+ | 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
39
 
30
40
  **Collection is deliberately narrow about what it trusts.**
31
41
 
32
42
  | Decision | Why |
33
43
  |---|---|
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 |
44
+ | `--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 |
45
+ | A binary blob or file yields null content | Rather than lossily decoded bytes |
46
+ | The unborn first commit narrows to all-added | Rather than throwing — staged and worktree alike |
47
+ | 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 |
48
+ | Every listing ends with `--` | A branch that shares its name with a file is a ref, never an ambiguous argument |
38
49
 
39
50
  Translation produces one tool call per change, under the adapter-owned names `staged-write`
40
51
  and `staged-delete`. A deletion always carries its evidence. A write carries it unless the
@@ -86,7 +86,11 @@ 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.
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.
90
94
 
91
95
  **Context-family disciplines skip on the commit surface.** A commit has no session to look
92
96
  at, so a `requirePrecedent` entry cannot be judged there — demanding evidence a commit
@@ -173,9 +177,49 @@ recorded as `witnessed`, never silent.
173
177
 
174
178
  Optional. Each entry is one discipline: a practice the team imposes on itself, declared as
175
179
  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
+ telemetry label), and optionally a `why` (the reason, which travels with the block message
181
+ the agent reads) plus, on a `forbid` or `requirePrecedent` entry, `in` (the file globs it
182
+ judges) and `except` (globs carved out of that scope).
183
+
184
+ **`draft` — an unpromoted entry.** The one shape that carries no predicate:
185
+ `{ id, why, draft: true }` and nothing else. A draft registers a practice as prose ahead of
186
+ its promotion — it makes no judgment and no telemetry record on either surface, and
187
+ `pdks explain` shows it as `unpromoted`. `why` is required here (the prose is the entry's
188
+ whole body), and the marker must be the literal `true` — a draft is declared, never
189
+ inferred, so an entry with neither a predicate nor `draft: true` is still a validation
190
+ error, and `draft: false` is rejected as dead data.
191
+
192
+ ```yaml
193
+ disciplines:
194
+ - id: 'bilingual-docs-sync'
195
+ why: 'en and ko doc mirrors must move together.'
196
+ draft: true
197
+ ```
198
+
199
+ A `why` is never judged — it changes no verdict. It is appended to the break message once a
200
+ verdict has blocked, so whoever reads the block gets the rationale in the same line instead
201
+ of having to open this file. A `why` spanning several lines is folded to spaces: the message
202
+ is one line.
203
+
204
+ **`enforce` — the entry's own level.** Optional on any judged entry: `block` or `advise`.
205
+ **Absent means `advise`.** Under `advise` a break is recorded as an `advised` telemetry
206
+ event and the call proceeds (exit 0), with the break message still written to stderr;
207
+ `block` is the promotion — it pins the entry at block. The entry's level composes with the
208
+ surface's (`adapters.git.enforce` on the commit surface; the session surface has none) and
209
+ the lenient side wins — an `advise` on either axis makes the entry advise, and an explicit
210
+ `block` never raises a surface the observer set to advise. An unjudgeable body (never
211
+ built, or one that cannot be loaded) still blocks whatever the level. A draft carries no
212
+ `enforce`; any
213
+ other value is rejected at load time. `pdks explain` prints the level an entry declares
214
+ (`enforce: block` or `enforce: advise`) on both surfaces and leaves an absent one unmarked;
215
+ the session header states the default.
216
+
217
+ ```yaml
218
+ - id: 'no-console-log'
219
+ why: 'console output belongs to the logger; measure the habit before blocking it.'
220
+ forbid: 'console\.log\('
221
+ enforce: advise
222
+ ```
179
223
 
180
224
  **`forbid` — content delta.** Blocks an edit that *adds* a new match of the pattern.
181
225
  Existing occurrences are forgiven: adopting a discipline never blocks a legacy codebase,
@@ -29,8 +29,10 @@ can check. Every other package depends on this one; this one depends on none of
29
29
 
30
30
  ## The judged protocol
31
31
 
32
- This is the contract the shipped judge bodies speak: a body reads a `CovenantInput` from
33
- stdin and answers with an exit code. Every row in `.polydeukes/roi.log` traces back to one
32
+ This is the contract the shipped judges speak: a judge receives a `CovenantInput` — parsed
33
+ once from the stdin-JSON payload the surface hands the dispatcher — and answers with a
34
+ verdict the wrapper translates into an exit code. Every row in `.polydeukes/roi.log` traces
35
+ back to one
34
36
  of these verdicts, so this vocabulary is what a blocked row is written in.
35
37
 
36
38
  ```ts
@@ -58,7 +58,7 @@ the same word for the same event. How to read a row is in
58
58
  | `passed` | The call was judged and upheld the covenant |
59
59
  | `blocked` | The call was judged and broke it |
60
60
  | `witnessed` | A **blocked** verdict a human opened in person. Never silent, never a clean call |
61
- | `advised` | The commit surface at `enforce: advise` recorded a break without stopping it |
61
+ | `advised` | A break recorded without stopping the call — the default for every discipline entry on both surfaces, and the commit surface's outcome under `adapters.git.enforce: advise` |
62
62
  | `skipped` | The call reached a registration that could not judge it. **Not a pass** — the recorded absence of a judgment |
63
63
  | `unattributed` | A protected entry's on-disk state moved and no judgment row explains it. **Not a verdict** — no call is blocked or passed by it; the session surface writes it after comparing state against a stored baseline |
64
64