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.
- package/README.ko.md +11 -4
- package/README.md +21 -5
- package/dist/baseline.d.ts +82 -0
- package/dist/baseline.js +166 -0
- package/dist/bin.d.ts +5 -6
- package/dist/bin.js +92 -38
- package/dist/claude-code-hook.d.ts +40 -17
- package/dist/claude-code-hook.js +187 -175
- package/dist/claude-code.d.ts +6 -0
- package/dist/claude-code.js +6 -0
- package/dist/covenant-check.d.ts +50 -36
- package/dist/covenant-check.js +167 -193
- package/dist/covenant-module.d.ts +25 -0
- package/dist/covenant-module.js +42 -0
- package/dist/docs/configuration.md +17 -9
- package/dist/docs/installation.md +42 -12
- package/dist/docs/reference/adapter-claude-code.md +6 -4
- package/dist/docs/reference/adapter-git.md +23 -10
- package/dist/docs/reference/configuration.md +265 -103
- package/dist/docs/reference/core.md +15 -7
- package/dist/docs/reference/covenant.md +32 -24
- package/dist/docs/reference/polydeukes.md +132 -32
- package/dist/docs/troubleshooting.md +37 -8
- package/dist/docs-query.d.ts +10 -10
- package/dist/docs-query.js +12 -12
- package/dist/explain.d.ts +25 -0
- package/dist/explain.js +153 -0
- package/dist/index.d.ts +11 -16
- package/dist/index.js +10 -15
- package/dist/init-claude-code.d.ts +31 -18
- package/dist/init-claude-code.js +254 -40
- package/dist/init-grok.d.ts +51 -0
- package/dist/init-grok.js +242 -0
- package/dist/load-config.d.ts +17 -15
- package/dist/load-config.js +13 -12
- package/dist/pre-state-reader.d.ts +22 -0
- package/dist/pre-state-reader.js +32 -0
- package/dist/scaffold-project.d.ts +23 -14
- package/dist/scaffold-project.js +97 -32
- package/dist/schema/polydeukes.schema.json +54 -81
- package/package.json +7 -7
package/dist/covenant-check.js
CHANGED
|
@@ -1,58 +1,28 @@
|
|
|
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, observationSourceReader, 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';
|
|
18
|
+
import { unobservedPreStateReader } from './pre-state-reader.js';
|
|
37
19
|
/**
|
|
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).
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
112
|
-
*
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
- **
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
188
|
-
freeze the project until a human edits the config from their own
|
|
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
|
|