polydeukes 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.ko.md +11 -4
  2. package/README.md +21 -5
  3. package/dist/baseline.d.ts +82 -0
  4. package/dist/baseline.js +166 -0
  5. package/dist/bin.d.ts +5 -6
  6. package/dist/bin.js +92 -38
  7. package/dist/claude-code-hook.d.ts +40 -17
  8. package/dist/claude-code-hook.js +187 -175
  9. package/dist/claude-code.d.ts +6 -0
  10. package/dist/claude-code.js +6 -0
  11. package/dist/covenant-check.d.ts +50 -36
  12. package/dist/covenant-check.js +167 -193
  13. package/dist/covenant-module.d.ts +25 -0
  14. package/dist/covenant-module.js +42 -0
  15. package/dist/docs/configuration.md +17 -9
  16. package/dist/docs/installation.md +42 -12
  17. package/dist/docs/reference/adapter-claude-code.md +6 -4
  18. package/dist/docs/reference/adapter-git.md +23 -10
  19. package/dist/docs/reference/configuration.md +265 -103
  20. package/dist/docs/reference/core.md +15 -7
  21. package/dist/docs/reference/covenant.md +32 -24
  22. package/dist/docs/reference/polydeukes.md +132 -32
  23. package/dist/docs/troubleshooting.md +37 -8
  24. package/dist/docs-query.d.ts +10 -10
  25. package/dist/docs-query.js +12 -12
  26. package/dist/explain.d.ts +25 -0
  27. package/dist/explain.js +153 -0
  28. package/dist/index.d.ts +11 -16
  29. package/dist/index.js +10 -15
  30. package/dist/init-claude-code.d.ts +31 -18
  31. package/dist/init-claude-code.js +254 -40
  32. package/dist/init-grok.d.ts +51 -0
  33. package/dist/init-grok.js +242 -0
  34. package/dist/load-config.d.ts +17 -15
  35. package/dist/load-config.js +13 -12
  36. package/dist/pre-state-reader.d.ts +22 -0
  37. package/dist/pre-state-reader.js +32 -0
  38. package/dist/scaffold-project.d.ts +23 -14
  39. package/dist/scaffold-project.js +97 -32
  40. package/dist/schema/polydeukes.schema.json +54 -81
  41. package/package.json +7 -7
@@ -1,58 +1,28 @@
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, observationSourceReader, 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';
18
+ import { unobservedPreStateReader } from './pre-state-reader.js';
37
19
  /**
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).
20
+ * The TTY witness predicate, or undefined when no valve can exist (no witness configured
21
+ * or no TTY seam). It fires on the first registration that broke, names it from the
22
+ * dispatcher's context, and caches the answer: one commit, at most one prompt, full-token
23
+ * equality. Both sides are trimmed like the session valve, since config validation accepts
24
+ * a padded token. The cache latches closed before the seam is consulted so a throwing seam
25
+ * never re-prompts.
56
26
  */
57
27
  function ttyWitnessValve(witness, ttyPrompt) {
58
28
  if (witness === undefined || ttyPrompt === undefined)
@@ -71,32 +41,11 @@ function ttyWitnessValve(witness, ttyPrompt) {
71
41
  return verdict;
72
42
  };
73
43
  }
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
44
  /**
91
45
  * 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.
46
+ * `appendRecordFailOpen` creates the missing `.polydeukes/` of a never-judged repository,
47
+ * and a telemetry failure never softens the exit. An undefined path (non-string
48
+ * `repoRoot`) leaves no root to write under.
100
49
  */
101
50
  function recordFailClosed(telemetryPath) {
102
51
  if (telemetryPath === undefined)
@@ -108,155 +57,180 @@ function recordFailClosed(telemetryPath) {
108
57
  });
109
58
  }
110
59
  /**
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.
60
+ * The commit surface's registration set — one assembly that the runner dispatches and
61
+ * `explain` renders.
115
62
  */
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).
63
+ export function assembleCommitRegistrations(spec) {
64
+ const { config, rootDir, covenant, witness } = spec;
65
+ const { protectedPaths: gitAdditivePaths } = resolveGitAdapterSettings({
66
+ namespace: config.adapters?.git,
67
+ });
68
+ // Union of the common list and the git-additive one, common first so first-occurrence
69
+ // dedupe is deterministic. The session hook reads the common list alone.
70
+ const protectedPaths = normalizeProtectedPaths({
71
+ protectedPaths: [...(config.protectedPaths ?? []), ...gitAdditivePaths],
72
+ });
73
+ const disciplines = config.disciplines ?? [];
74
+ const registrations = [
75
+ covenant.selfModRegistration({
76
+ protectedPaths,
77
+ mutatingToolNames: [STAGED_WRITE, STAGED_DELETE],
78
+ witness,
79
+ }),
80
+ ...covenant.compileDisciplineRegistrations({
81
+ disciplines,
82
+ rootDir,
83
+ shellTools: [],
84
+ commandArgs: [],
85
+ readPreState: unobservedPreStateReader,
86
+ observesChangeSet: true,
87
+ witness,
88
+ }),
89
+ ];
90
+ return registrations;
91
+ }
92
+ /** One stage's failure disposition: the stderr line, the recorded row, and exit 2. */
93
+ function failClosed(telemetryPath, error) {
94
+ process.stderr.write(`covenant check failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
95
+ recordFailClosed(telemetryPath);
96
+ return { exitCode: 2 };
97
+ }
98
+ /**
99
+ * Settle the telemetry path and load the config once, or fail closed. The provisional
100
+ * path is settled before the load so a config that never loads still has somewhere to
101
+ * write its blocked row; both terms use `resolve` so a relative `repoRoot` cannot send
102
+ * them to different files. The provisional term sits inside the try because `resolve`
103
+ * throws on a non-string `repoRoot`.
104
+ */
105
+ function settleConfig(spec) {
134
106
  let telemetryPath;
135
- let config;
136
107
  try {
137
108
  telemetryPath = spec.telemetryPath ?? resolve(spec.repoRoot, DEFAULT_TELEMETRY_LOG_PATH);
138
- ({ config } = loadConfig(spec.repoRoot));
109
+ const { config } = loadConfig({ rootDir: spec.repoRoot });
139
110
  telemetryPath = spec.telemetryPath ?? resolve(spec.repoRoot, config.telemetry.logPath);
111
+ return { settled: true, telemetryPath, config };
140
112
  }
141
113
  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);
114
+ return { settled: false, ...failClosed(telemetryPath, error) };
149
115
  }
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 };
116
+ }
117
+ /**
118
+ * Collect the changes of one domain. The three collectors return the same shape, so
119
+ * everything downstream of this dispatch is one path.
120
+ */
121
+ function collectDomain(repoRoot, domain) {
122
+ if (domain.kind === 'worktree')
123
+ return collectWorktreeChanges({ repoRoot });
124
+ if (domain.kind === 'range') {
125
+ const separator = domain.ancestry === 'merge-base' ? '...' : '..';
126
+ return collectRangeChanges({
127
+ repoRoot,
128
+ range: `${domain.base}${separator}${domain.head}`,
129
+ });
154
130
  }
155
- if (changes.length === 0) {
156
- return { exitCode: 0 };
131
+ return collectStagedChanges({ repoRoot });
132
+ }
133
+ /**
134
+ * The observation's change set: the paths of the collected changes that carry file-change
135
+ * evidence, in collection order.
136
+ *
137
+ * The same definition the judge derives its own set from, so both surfaces name the same
138
+ * changes. A deletion carries evidence and stays; a binary blob, which the collector gives
139
+ * a call with no evidence, produces no world of its own — listing it would hand the
140
+ * change-set relations a path no world can ever answer for.
141
+ */
142
+ function changedPaths(changes) {
143
+ const paths = [];
144
+ for (const call of covenantInputFromStagedChanges({ changes }).toolCalls) {
145
+ if (call.fileChange !== undefined)
146
+ paths.push(call.fileChange.path);
157
147
  }
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).
148
+ return paths;
149
+ }
150
+ /**
151
+ * Assemble the registrations and dispatch every collected change. Any throw here (an
152
+ * unbuilt dist, a registration-build failure) is unjudgeable: block and leave one record.
153
+ */
154
+ async function judgeChanges(spec, domain, telemetryPath, config, changes) {
163
155
  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
- }
156
+ // Inside the try so an invalid adapter namespace fails closed.
157
+ const { enforce } = resolveGitAdapterSettings({ namespace: config.adapters?.git });
158
+ // Real Node resolution of the covenant package, so the commit surface runs the same
159
+ // judges the session hook does; tests inject a directory instead. Awaited before any
160
+ // registration is composed, so a dist the barrel cannot load fails the run closed here
161
+ // rather than leaving a half-judged table behind.
162
+ const covenantDist = spec.covenantDist ?? resolveCovenantDist();
163
+ const covenant = await loadCovenantModule(covenantDist);
164
+ // No valve under advise (nothing to witness) and none outside `staged`.
165
+ const witness = enforce === 'advise' || domain.kind !== 'staged'
166
+ ? undefined
167
+ : ttyWitnessValve(config.witness, spec.ttyPrompt);
237
168
  let blocked = false;
238
169
  let advisedCount = 0;
170
+ // Assembled ONCE for the run, not per change: a judge takes its call set as an argument,
171
+ // so the table is payload-free. Recompiling per file would repeat every compile-time
172
+ // side effect — the stderr line a config-faulted discipline names itself with would
173
+ // print once per staged file rather than once.
174
+ const registrations = assembleCommitRegistrations({
175
+ config,
176
+ rootDir: spec.repoRoot,
177
+ covenant,
178
+ witness,
179
+ });
180
+ // One plan and one supply for the run: the per-change loop shares them, so the tree is
181
+ // read once per named file rather than once per change. `changes` carries the whole
182
+ // observation because this surface dispatches one change at a time to keep telemetry at
183
+ // one row per file — a set no judge could derive from the input it is handed.
184
+ const { files } = covenant.supplySources({
185
+ plan: covenant.planSources({ registrations }),
186
+ read: observationSourceReader({ repoRoot: spec.repoRoot, observation: domain }),
187
+ });
188
+ const world = { files, changes: changedPaths(changes) };
239
189
  for (const change of changes) {
240
- const input = covenantInputFromStagedChanges([change]);
241
- const { exitCode, results } = await dispatchCovenants({
190
+ const input = covenantInputFromStagedChanges({ changes: [change] });
191
+ const { exitCode, results } = await covenant.dispatchCovenants({
242
192
  stdinPayload: JSON.stringify(input),
243
193
  registrations,
244
194
  telemetryPath,
245
195
  dispatcherLabel: 'covenant-check',
246
196
  enforce,
197
+ world,
247
198
  });
248
199
  if (exitCode === 2)
249
200
  blocked = true;
250
201
  advisedCount += results.filter((result) => result.event === 'advised').length;
251
202
  }
203
+ // Names no level: surface-level and entry-level advice mix in one run, so the commit's
204
+ // fate is read from the run.
252
205
  if (advisedCount > 0) {
253
- process.stderr.write(`covenant advisory (enforce: advise): ${advisedCount} verdict(s) recorded, commit allowed\n`);
206
+ const outcome = blocked ? 'commit blocked by another verdict' : 'commit allowed';
207
+ process.stderr.write(`covenant advisory: ${advisedCount} verdict(s) recorded as advised, ${outcome}\n`);
254
208
  }
255
209
  return { exitCode: blocked ? 2 : 0 };
256
210
  }
257
211
  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 };
212
+ return failClosed(telemetryPath, error);
261
213
  }
262
214
  }
215
+ /**
216
+ * Judge one observation of `repoRoot` exactly as the session surface would — the staged
217
+ * diff by default, the working tree or a ref range on request. Async because the dispatcher
218
+ * spawns covenant bodies. An empty domain is an explicit pass: nothing to judge, no records.
219
+ */
220
+ export async function runCovenantCheck(spec) {
221
+ const settlement = settleConfig(spec);
222
+ if (!settlement.settled)
223
+ return { exitCode: settlement.exitCode };
224
+ const { telemetryPath, config } = settlement;
225
+ const domain = spec.domain ?? { kind: 'staged' };
226
+ let changes;
227
+ try {
228
+ changes = collectDomain(spec.repoRoot, domain);
229
+ }
230
+ catch (error) {
231
+ return failClosed(telemetryPath, error);
232
+ }
233
+ if (changes.length === 0)
234
+ return { exitCode: 0 };
235
+ return judgeChanges(spec, domain, telemetryPath, config, changes);
236
+ }
@@ -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 — the members they call, and no more. */
16
+ export type CovenantModule = Pick<typeof covenant, 'dispatchCovenants' | 'compileDisciplineRegistrations' | 'selfModRegistration' | 'shellModRegistration' | 'transcriptModRegistration' | 'planSources' | 'supplySources'>;
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,42 @@
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
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
15
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
16
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
17
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
18
+ });
19
+ }
20
+ return path;
21
+ };
22
+ import { createRequire } from 'node:module';
23
+ import { join } from 'node:path';
24
+ import { pathToFileURL } from 'node:url';
25
+ /** Where real Node resolution puts the covenant package's built barrel. */
26
+ export function resolveCovenantDist() {
27
+ return join(createRequire(import.meta.url).resolve('@polydeukes/covenant'), '..');
28
+ }
29
+ /**
30
+ * Import the covenant barrel from `distDir`, naming the recovery command when it will not
31
+ * load. The message carries the loader's own text, which names the module that is missing;
32
+ * a reader locked out by an unbuilt or half-built dist needs both that name and the one
33
+ * command that fixes it.
34
+ */
35
+ export async function loadCovenantModule(distDir) {
36
+ try {
37
+ return (await import(__rewriteRelativeImportExtension(pathToFileURL(join(distDir, 'index.js')).href)));
38
+ }
39
+ catch (error) {
40
+ 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)}`);
41
+ }
42
+ }
@@ -3,7 +3,7 @@
3
3
  **English** · [한국어](./configuration.ko.md)
4
4
 
5
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
6
+ > the declaration grammar). Fields and steps will grow; what is
7
7
  > written here is tested and enforced now.
8
8
 
9
9
  `polydeukes.config.yaml` is the one file where a project declares its disciplines — the
@@ -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.
@@ -43,7 +43,8 @@ judge from your project's own installed package.
43
43
  From the project root:
44
44
 
45
45
  ```sh
46
- pnpm exec pdks init claude-code
46
+ pnpm exec pdks init claude-code # Claude Code
47
+ pnpm exec pdks init grok # Grok
47
48
  ```
48
49
 
49
50
  The command installs into the directory it is invoked from, and it proves the `polydeukes`
@@ -51,9 +52,17 @@ package resolves there **before writing anything** — if it does not (say, the
51
52
  was skipped), it prints the install command and exits 2 with zero files written, never a
52
53
  half-wired tree.
53
54
 
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:
55
+ Nothing existing is overwritten. What exists is reported and kept — the hook, the config,
56
+ and the discipline files are left alone, the settings file is merged, and `.gitignore` is
57
+ only ever appended to — so re-running is always safe. One command-field exception: if
58
+ `.grok/hooks/covenant-pretooluse.json` still names the grok delegator and a Claude
59
+ delegator is on disk, the JSON `command` is rewritten to that Claude file so the host
60
+ does not spawn two judges. Grok collapses two registrations only when `command` AND
61
+ `matcher` are identical, so every grok entry naming that Claude file also takes the matcher
62
+ of the `.claude/settings.json` entry that registers the same command — on a fresh write and
63
+ on every re-run; `timeout` stays. A command you pointed elsewhere is left as it was.
64
+
65
+ `pdks init claude-code` writes six artifacts:
57
66
 
58
67
  | Artifact | What it is |
59
68
  |---|---|
@@ -61,8 +70,28 @@ config, and the discipline file are left alone, the settings file is merged, and
61
70
  | `.claude/settings.json` | The PreToolUse registration for editing tools and shell calls. **Merged, never replaced** — your other hooks and permissions stay. |
62
71
  | `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
72
  | `.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. |
73
+ | `.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
74
  | `.gitignore` | An appended ignore rule for `.polydeukes/`, with its comment line — telemetry is local observation data and never belongs in history. |
65
75
 
76
+ `pdks init grok` shares the scaffold (config and the ignore line) and writes Grok's own
77
+ registration. A Grok-only tree has four artifacts, and no `.claude/` directory:
78
+
79
+ | Artifact | What it is |
80
+ |---|---|
81
+ | `.grok/hooks/covenant-pretooluse.mjs` | The hook — the same delegator text, only when no Claude delegator is already on disk. |
82
+ | `.grok/hooks/covenant-pretooluse.json` | The PreToolUse matcher, `timeout` 60 (the host default is 5 seconds, and a timed-out hook fails open), and the command that names one delegator file. In a tree that also has `.claude/settings.json`, the matcher is copied from the settings entry with the same command — Grok reads that file too, and collapses the two registrations into one spawn only when `command` and `matcher` match exactly. That copy leans on Grok's tool-name aliases, so if you later remove `.claude/settings.json`, delete this JSON and run `pdks init grok` again to get the Grok-native matcher back. |
83
+ | `polydeukes.config.yaml` | The same starter policy as above. |
84
+ | `.gitignore` | The same appended ignore line. |
85
+
86
+ If `.claude/hooks/covenant-pretooluse.mjs` already exists, the JSON command points at that
87
+ file instead of planting a second one. A later `pdks init grok` or `pdks init claude-code`
88
+ retargets an installer-generated grok-mjs command the same way.
89
+
90
+ An already-open Grok session keeps the hook snapshot from start. Reload from the Hooks tab
91
+ (`r`) or start a new session. The witness valve does not open on Grok — the session log is
92
+ ACP `updates.jsonl`, not Claude's JSONL. A block is recovered from another terminal or the
93
+ commit-surface TTY.
94
+
66
95
  ## First edit — `languages`
67
96
 
68
97
  The generated config ships a placeholder language profile, because the installer cannot
@@ -147,12 +176,12 @@ Three things to know about this surface:
147
176
  - **The valve is a TTY prompt.** At the default `block` level, a commit that stages a
148
177
  protected change stops at a prompt only a human at a terminal can answer. Configure your
149
178
  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.
179
+ - **Declarations judge here.** A staged diff carries file changes and nothing else, so
180
+ protection lists and every `declare` entry over the change judge in full. A declaration
181
+ scoped on `command` has no command line to read in a staged diff and observes nothing on
182
+ this surface, and a declaration that reads the session (`precedent` and the other history
183
+ mechanisms) is recorded as `skipped` — declare those where an AI partner's session exists
184
+ to be judged.
156
185
  - **The commit surface has its own additive scope.** Paths that are fine to edit freely
157
186
  but whose promotion into history deserves a judged checkpoint go under the adapter
158
187
  namespace, judged on top of the shared list:
@@ -184,8 +213,9 @@ witness:
184
213
 
185
214
  Change the token and window as you like — the token is not a secret; the defence is
186
215
  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.
216
+ protection list covers `.claude/hooks` and `.grok/hooks`, so without a valve the first
217
+ blocked call would freeze the project until a human edits the config from their own
218
+ terminal.
189
219
 
190
220
  ## Prove the gate is live
191
221