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.
- package/README.ko.md +3 -2
- package/README.md +10 -2
- package/dist/bin.d.ts +5 -6
- package/dist/bin.js +75 -38
- package/dist/claude-code-hook.d.ts +35 -14
- package/dist/claude-code-hook.js +128 -168
- package/dist/covenant-check.d.ts +54 -33
- package/dist/covenant-check.js +135 -193
- package/dist/covenant-module.d.ts +25 -0
- package/dist/covenant-module.js +34 -0
- package/dist/docs/configuration.md +16 -8
- package/dist/docs/installation.md +3 -2
- package/dist/docs/reference/adapter-git.md +16 -5
- package/dist/docs/reference/configuration.md +48 -4
- package/dist/docs/reference/core.md +4 -2
- package/dist/docs/reference/covenant.md +1 -1
- package/dist/docs/reference/polydeukes.md +95 -23
- package/dist/docs/troubleshooting.md +3 -1
- package/dist/docs-query.d.ts +10 -10
- package/dist/docs-query.js +12 -12
- package/dist/explain.d.ts +27 -0
- package/dist/explain.js +152 -0
- package/dist/index.d.ts +10 -14
- package/dist/index.js +9 -13
- package/dist/init-claude-code.d.ts +28 -17
- package/dist/init-claude-code.js +208 -40
- package/dist/load-config.d.ts +12 -14
- package/dist/load-config.js +11 -11
- package/dist/scaffold-project.d.ts +23 -14
- package/dist/scaffold-project.js +51 -26
- package/dist/schema/polydeukes.schema.json +30 -4
- package/package.json +5 -5
package/dist/covenant-check.js
CHANGED
|
@@ -1,58 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `pdks covenant check` — the
|
|
2
|
+
* `pdks covenant check` — the commit surface's composition root.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
31
|
-
import {
|
|
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 {
|
|
16
|
+
import { loadCovenantModule, resolveCovenantDist } from './covenant-module.js';
|
|
36
17
|
import { loadConfig } from './load-config.js';
|
|
37
18
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
-
*
|
|
94
|
-
*
|
|
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
|
-
*
|
|
112
|
-
*
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
89
|
-
discipline's `id` in the telemetry record
|
|
90
|
-
|
|
91
|
-
`
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
55
|
-
config, and the discipline
|
|
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
|
-
##
|
|
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
|
-
|
|
|
36
|
-
|
|
|
37
|
-
|
|
|
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
|
|
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,
|
|
177
|
-
`forbid` or `requirePrecedent` entry, `in` (the file globs it
|
|
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
|
|
33
|
-
|
|
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` |
|
|
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
|
|