polydeukes 0.3.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,57 +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 when a telemetry path is known. An empty staging area
26
- * is an explicit pass (nothing to judge — the dispatcher precedent of zero matches, zero
27
- * 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.
28
12
  */
29
- import { existsSync } from 'node:fs';
30
- import { createRequire } from 'node:module';
31
- import { dirname, join, resolve } from 'node:path';
32
- import { collectStagedChanges, covenantInputFromStagedChanges, resolveGitAdapterSettings, STAGED_DELETE, STAGED_WRITE, } from '@polydeukes/adapter-git';
33
- import { appendRecord, normalizeProtectedPaths } from '@polydeukes/core';
34
- import { compileDisciplineRegistrations, dispatchCovenants, } from '@polydeukes/covenant';
13
+ import { resolve } from 'node:path';
14
+ import { collectRangeChanges, collectStagedChanges, collectWorktreeChanges, covenantInputFromStagedChanges, resolveGitAdapterSettings, STAGED_DELETE, STAGED_WRITE, } from '@polydeukes/adapter-git';
15
+ import { appendRecordFailOpen, DEFAULT_TELEMETRY_LOG_PATH, normalizeProtectedPaths, } from '@polydeukes/core';
16
+ import { loadCovenantModule, resolveCovenantDist } from './covenant-module.js';
35
17
  import { loadConfig } from './load-config.js';
36
18
  /**
37
- * Build the witness predicate for the TTY valve, or undefined when no valve can exist
38
- * (no witness configured, or no TTY seam — both leave the dispatcher with no way to open
39
- * one at all). The valve IS the witness: the judge has already broken, and the human at
40
- * the terminal supplies the pass condition themselves, sudo-style. The prompt fires
41
- * lazily on the first registration that actually BROKE and names it from the dispatcher's
42
- * context (COVENANT-17 §4.5) — the label and the MATCHED entry, the same subject the
43
- * telemetry row carries, so screen and log never disagree. The human reads what broke,
44
- * on what, and how far one answer reaches. The verdict is cached: one commit, at most
45
- * one prompt, full-token equality only — and the token itself is never printed, or
46
- * typing it from memory would become copying it off the screen.
47
- *
48
- * Both comparison sides are trimmed, mirroring the session valve: `ttlWitness` trims the
49
- * config token at assembly precisely because config validation accepts a padded value,
50
- * and it compares the utterance's first line trimmed — without the same normalisation
51
- * here, one padded token would open the session surface and permanently shut this one
52
- * (PR #41 review). The cache latches CLOSED before the seam is consulted: a throwing
53
- * seam must not retry on the next broken registration, or the prompt's own commit-wide
54
- * 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.
55
25
  */
56
26
  function ttyWitnessValve(witness, ttyPrompt) {
57
27
  if (witness === undefined || ttyPrompt === undefined)
@@ -71,150 +41,123 @@ function ttyWitnessValve(witness, ttyPrompt) {
71
41
  };
72
42
  }
73
43
  /**
74
- * Compose a judge body's module path and prove the file is there (CONFIG-06b §4.2).
75
- * Spawning an absent module succeeds and its child exits 1 — the code a break verdict
76
- * returns — so a judge that ran no line would arrive as a violation and, under `advise`,
77
- * be waved through. Nothing downstream can separate the two (`translateExitCode` sees
78
- * that number alone), so the proof happens here, before the spawn. Producing the path
79
- * and proving it are one step on purpose: a path that skipped the proof cannot be
80
- * constructed, and only the bodies this surface actually composes are proven.
44
+ * One blocked record for a run that failed closed before any dispatch could judge.
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.
81
48
  */
82
- function provenBodyPath(distDir, fileName) {
83
- const modulePath = join(distDir, fileName);
84
- if (!existsSync(modulePath)) {
85
- throw new Error(`judge body ${modulePath} is missing — run 'pnpm build' to rebuild it`);
86
- }
87
- return modulePath;
88
- }
89
- /** One blocked record for a run that failed closed before any dispatch could judge. */
90
49
  function recordFailClosed(telemetryPath) {
91
50
  if (telemetryPath === undefined)
92
51
  return;
93
- try {
94
- appendRecord(telemetryPath, {
95
- timestamp: new Date().toISOString(),
96
- event: 'blocked',
97
- label: 'covenant-check',
98
- subject: '-',
99
- });
100
- }
101
- catch {
102
- // A telemetry failure must never soften the blocking exit (session-hook precedent).
103
- }
52
+ appendRecordFailOpen(telemetryPath, {
53
+ event: 'blocked',
54
+ label: 'covenant-check',
55
+ subject: '-',
56
+ });
104
57
  }
105
58
  /**
106
- * Judge the staged changes of `repoRoot` exactly as the session surface would
107
- * (ADAPTER-git §4.3). Async because the dispatcher spawns covenant bodies (CORE-01) —
108
- * a synchronous runner would mean reimplementing the judge, which the single-dispatcher
109
- * principle forbids.
59
+ * The commit surface's registration set — one assembly that the runner dispatches and
60
+ * `explain` renders.
110
61
  */
111
- export async function runCovenantCheck(spec) {
112
- let config;
113
- try {
114
- ({ config } = loadConfig(spec.repoRoot));
115
- }
116
- catch (error) {
117
- process.stderr.write(`covenant check failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
118
- recordFailClosed(spec.telemetryPath);
119
- return { exitCode: 2 };
120
- }
121
- const telemetryPath = spec.telemetryPath ?? resolve(spec.repoRoot, config.telemetry.logPath);
122
- let changes;
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) {
104
+ let telemetryPath;
123
105
  try {
124
- changes = collectStagedChanges(spec.repoRoot);
106
+ telemetryPath = spec.telemetryPath ?? resolve(spec.repoRoot, DEFAULT_TELEMETRY_LOG_PATH);
107
+ const { config } = loadConfig(spec.repoRoot);
108
+ telemetryPath = spec.telemetryPath ?? resolve(spec.repoRoot, config.telemetry.logPath);
109
+ return { settled: true, telemetryPath, config };
125
110
  }
126
111
  catch (error) {
127
- process.stderr.write(`covenant check failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
128
- recordFailClosed(telemetryPath);
129
- return { exitCode: 2 };
112
+ return { settled: false, ...failClosed(telemetryPath, error) };
130
113
  }
131
- if (changes.length === 0) {
132
- 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}`);
133
125
  }
134
- // Everything from here on is judgment assembly and dispatch: any throw (an unbuilt or
135
- // unresolvable covenant dist, a registration-build failure) is unjudgeable and must
136
- // both block AND leave one blocked record — the session hook's one-call-one-record
137
- // invariant, which an unrecorded propagation to the bin's catch would narrow
138
- // (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) {
139
133
  try {
140
- // The adapter namespace validator throws on unknown levels/keys (CONFIG-06 §4.2) —
141
- // resolved inside this try so a misconfiguration fails closed, never softens.
142
- const { enforce, protectedPaths: gitAdditivePaths } = resolveGitAdapterSettings(config.adapters?.git);
143
- // The commit surface judges the UNION of the common list and the git namespace's
144
- // additive one (CONFIG-08 §4.2) — common first, so first-occurrence dedupe inside
145
- // the one normalization pass is deterministic. The session hook reads the common
146
- // list alone; that asymmetry is the contract, not an omission.
147
- const protectedPaths = normalizeProtectedPaths({
148
- protectedPaths: [...(config.protectedPaths ?? []), ...gitAdditivePaths],
149
- });
150
- // The judge bodies are the covenant package's dist executables — resolved through
151
- // the real package (never a test alias), so the commit surface spawns the same
152
- // judges the session hook does. An injected directory overrides that resolution:
153
- // `createRequire` is real Node resolution and always lands on the real build, which
154
- // no fixture can take a body away from.
155
- const covenantDist = spec.covenantDist ?? dirname(createRequire(import.meta.url).resolve('@polydeukes/covenant'));
156
- // Under advise the TTY valve is structurally absent (CONFIG-06 §4.6): a verdict
157
- // already passes, so there is nothing to witness and the prompt must never fire.
158
- const witness = enforce === 'advise' ? undefined : ttyWitnessValve(config.witness, spec.ttyPrompt);
159
- const disciplines = config.disciplines ?? [];
160
- const registrations = [
161
- {
162
- label: 'self-mod',
163
- protectedPaths,
164
- body: {
165
- command: process.execPath,
166
- args: [
167
- provenBodyPath(covenantDist, 'self-mod-body.js'),
168
- ...protectedPaths.flatMap((path) => ['--protected-path', path]),
169
- ...[STAGED_WRITE, STAGED_DELETE].flatMap((tool) => ['--mutating-tool', tool]),
170
- ],
171
- },
172
- witness,
173
- },
174
- // Command-family entries are excluded: the commit surface has no shell axis (a
175
- // staged diff carries no commands), so registering them would be spawn waste by
176
- // design (PRD §2) — a vacuous exclusion, hence recorded nowhere. Path and delta
177
- // families judge the staged fileChanges as-is.
178
- //
179
- // Context-family entries are NOT filtered out any more. No transcript is injected
180
- // here, so the compiler gives them skip registrations, and a skip records one
181
- // `skipped` exactly when its trigger matches a staged change (COVENANT-13 §4.5).
182
- // The commit surface stopped being a special case: an absent evidence channel gets
183
- // the same disposition on both surfaces, and the scope gate comes free with the
184
- // routing every registration already carries.
185
- //
186
- // The body path is passed as a thunk, so the proof fires only where the compiler
187
- // actually composes a body (CONFIG-06b §4.2 corollary). Entry count cannot stand in
188
- // for that: an entry may compile to a body-less skip — every `requirePrecedent` one
189
- // does here, since this surface injects neither transcript nor evaluator — and the
190
- // compiler appends the body-less `shell-unjudgeable` backstop even for zero entries,
191
- // so gating the call itself would drop that record.
192
- ...compileDisciplineRegistrations({
193
- disciplines: disciplines.filter((entry) => entry.forbidCommand === undefined),
194
- rootDir: spec.repoRoot,
195
- bodyCommand: process.execPath,
196
- bodyModulePath: () => provenBodyPath(covenantDist, 'discipline-body.js'),
197
- shellTools: [],
198
- commandArgs: [],
199
- witness,
200
- }),
201
- ];
202
- // The commit surface resolves the compiler through the installed package, so a
203
- // workspace whose dist predates the lazy body-path convention hands back the thunk
204
- // itself where a string belongs. `spawn` stringifies rather than rejects it, which
205
- // would spawn the judge on the thunk's own source text and record the exit 1 as a
206
- // verdict under a discipline's label — the confusion this ticket removes, arriving
207
- // through the build-skew door. Assert the shape and let the fail-closed catch answer.
208
- for (const registration of registrations) {
209
- if (registration.body !== undefined && typeof registration.body.args?.[0] !== 'string') {
210
- throw new Error(`covenant dist predates the lazy body-path convention (registration '${registration.label}') — run 'pnpm build'`);
211
- }
212
- }
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);
213
146
  let blocked = false;
214
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
+ });
215
158
  for (const change of changes) {
216
159
  const input = covenantInputFromStagedChanges([change]);
217
- const { exitCode, results } = await dispatchCovenants({
160
+ const { exitCode, results } = await covenant.dispatchCovenants({
218
161
  stdinPayload: JSON.stringify(input),
219
162
  registrations,
220
163
  telemetryPath,
@@ -225,14 +168,37 @@ export async function runCovenantCheck(spec) {
225
168
  blocked = true;
226
169
  advisedCount += results.filter((result) => result.event === 'advised').length;
227
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.
228
173
  if (advisedCount > 0) {
229
- 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`);
230
176
  }
231
177
  return { exitCode: blocked ? 2 : 0 };
232
178
  }
233
179
  catch (error) {
234
- process.stderr.write(`covenant check failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
235
- recordFailClosed(telemetryPath);
236
- return { exitCode: 2 };
180
+ return failClosed(telemetryPath, error);
237
181
  }
238
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
+ }