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/claude-code-hook.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `runClaudeCodeHook` — the assembled session-surface judgment runner
|
|
2
|
+
* `runClaudeCodeHook` — the assembled session-surface judgment runner.
|
|
3
3
|
*
|
|
4
4
|
* The session counterpart of {@link runCovenantCheck}, and the one place where the Claude
|
|
5
5
|
* Code adapter (tool vocabulary, up-translation) and the covenant package (dispatcher +
|
|
@@ -8,17 +8,14 @@
|
|
|
8
8
|
* a delegator that calls this function. That is what makes the session surface installable:
|
|
9
9
|
* a consumer registers a hook that resolves this package instead of copying assembly.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* root data config through {@link loadConfig} (CONFIG-03), which also attaches the config
|
|
15
|
-
* file to its own surface.
|
|
11
|
+
* The protection-policy data (protectedPaths / disciplines / witness) is read from the root
|
|
12
|
+
* data config through {@link loadConfig}, which also attaches the config file to its own
|
|
13
|
+
* surface.
|
|
16
14
|
*
|
|
17
|
-
* The valve is the TTL witness
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* utterance carries the transcript marking `findUserMessages()` admits.
|
|
15
|
+
* The valve is the TTL witness, judged over the JSONL transcript provider. The judge body
|
|
16
|
+
* always spawns, and only an outcome that translated to blocked consults the witness —
|
|
17
|
+
* `witnessed` rows are would-block only. Its defence is provenance rather than secrecy: only
|
|
18
|
+
* a real human utterance carries the transcript marking `findUserMessages()` admits.
|
|
22
19
|
*
|
|
23
20
|
* fail-closed: ANY failure — an unbuilt judge body, an unreadable stdin, a missing or
|
|
24
21
|
* invalid config file — resolves to `{ exitCode: 2 }` with one `blocked` record under the
|
|
@@ -26,33 +23,21 @@
|
|
|
26
23
|
* the cheapest bypass vector there is. Recovery from an unbuilt clone is `pnpm build` (it
|
|
27
24
|
* mentions no protected path, so it is never blocked).
|
|
28
25
|
*/
|
|
29
|
-
import {
|
|
30
|
-
import { createRequire } from 'node:module';
|
|
26
|
+
import { mkdirSync, readFileSync } from 'node:fs';
|
|
31
27
|
import { homedir } from 'node:os';
|
|
32
|
-
import {
|
|
33
|
-
import { COMMAND_ARGS,
|
|
34
|
-
import { appendRecordFailOpen, DEFAULT_TELEMETRY_LOG_PATH, normalizeProtectedPaths, readRecords, } from '@polydeukes/core';
|
|
35
|
-
import {
|
|
28
|
+
import { join, resolve } from 'node:path';
|
|
29
|
+
import { COMMAND_ARGS, MUTATING_TOOLS, runAdapterPath, SHELL_TOOLS, sessionChannelReader, sessionSourceReader, transcriptFromJsonlFile, transcriptPathFromPayload, } from '@polydeukes/adapter-claude-code';
|
|
30
|
+
import { appendRecordFailOpen, DEFAULT_TELEMETRY_LOG_PATH, isPlainObject, normalizeProtectedPaths, readRecords, } from '@polydeukes/core';
|
|
31
|
+
import { ttlWitness } from '@polydeukes/covenant';
|
|
32
|
+
import { findUnattributed, readBaseline, snapshotBaseline, writeBaseline } from './baseline.js';
|
|
33
|
+
import { loadCovenantModule, resolveCovenantDist } from './covenant-module.js';
|
|
36
34
|
import { loadConfig } from './load-config.js';
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
* never built makes node exit 1 — the same code a real break verdict returns — so nothing
|
|
40
|
-
* downstream can separate an unjudgeable run from a judged one. The proof therefore belongs
|
|
41
|
-
* to the act of composing the path, and a body this assembly composes no path for is never
|
|
42
|
-
* proven: the throw lands in the fail-closed catch below, one blocked record and exit 2.
|
|
43
|
-
*/
|
|
44
|
-
function provenBodyPath(distDir, fileName) {
|
|
45
|
-
const modulePath = join(distDir, fileName);
|
|
46
|
-
if (!existsSync(modulePath)) {
|
|
47
|
-
throw new Error(`judge body ${modulePath} is missing — run 'pnpm build' to rebuild it`);
|
|
48
|
-
}
|
|
49
|
-
return modulePath;
|
|
50
|
-
}
|
|
51
|
-
/** The label every post-hoc state comparison row carries (COVENANT-14 §2-d). */
|
|
35
|
+
import { sessionPreStateReader } from './pre-state-reader.js';
|
|
36
|
+
/** The label every post-hoc state comparison row carries. */
|
|
52
37
|
const BASELINE_LABEL = 'baseline';
|
|
53
38
|
/**
|
|
54
39
|
* Compare the protected entries' on-disk state against the stored baseline and record what
|
|
55
|
-
* moved with no judgment explaining it
|
|
40
|
+
* moved with no judgment explaining it.
|
|
56
41
|
*
|
|
57
42
|
* Runs at hook call START, before this call's own judgment rows land, so the window it reads
|
|
58
43
|
* is the one the previous comparison left open. Returns the record count as of right now —
|
|
@@ -68,9 +53,9 @@ function compareBaseline(spec) {
|
|
|
68
53
|
const { records } = readRecords(spec.telemetryPath);
|
|
69
54
|
const stored = readBaseline(baselinePath);
|
|
70
55
|
if (stored === null) {
|
|
71
|
-
// Absence and corruption are the same signal
|
|
72
|
-
//
|
|
73
|
-
//
|
|
56
|
+
// Absence and corruption are the same signal. The baseline file is NOT on the protection
|
|
57
|
+
// list — protecting it would need a comparison of its own — so its disappearance has to
|
|
58
|
+
// stay legible in the log instead.
|
|
74
59
|
appendRecordFailOpen(spec.telemetryPath, {
|
|
75
60
|
event: 'unattributed',
|
|
76
61
|
label: BASELINE_LABEL,
|
|
@@ -96,7 +81,7 @@ function compareBaseline(spec) {
|
|
|
96
81
|
}
|
|
97
82
|
}
|
|
98
83
|
/**
|
|
99
|
-
* Re-establish the baseline at hook call END
|
|
84
|
+
* Re-establish the baseline at hook call END.
|
|
100
85
|
*
|
|
101
86
|
* At call end rather than right after the comparison: refreshing at comparison time would
|
|
102
87
|
* miss whatever this call's own judged writes changed, leaving detection permanently one
|
|
@@ -115,7 +100,7 @@ function updateBaseline(spec) {
|
|
|
115
100
|
writeBaseline(join(dotDir, 'baseline.json'), snapshotBaseline({ rootDir: spec.repoRoot, entries: spec.entries }), new Date().toISOString());
|
|
116
101
|
}
|
|
117
102
|
/**
|
|
118
|
-
* Where the comparison writes and what it observes, or `undefined
|
|
103
|
+
* Where the comparison writes and what it observes, or `undefined`.
|
|
119
104
|
*
|
|
120
105
|
* The domain is derived from config rather than enumerated here, and the telemetry path is
|
|
121
106
|
* resolved by the same precedence the judgment uses so both land in one log. A config that
|
|
@@ -126,7 +111,7 @@ function updateBaseline(spec) {
|
|
|
126
111
|
function comparisonSpec(spec) {
|
|
127
112
|
let config;
|
|
128
113
|
try {
|
|
129
|
-
config = loadConfig(spec.repoRoot).config;
|
|
114
|
+
config = loadConfig({ rootDir: spec.repoRoot }).config;
|
|
130
115
|
}
|
|
131
116
|
catch {
|
|
132
117
|
return undefined;
|
|
@@ -140,20 +125,127 @@ function comparisonSpec(spec) {
|
|
|
140
125
|
};
|
|
141
126
|
}
|
|
142
127
|
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
128
|
+
* The session surface's registration set. One assembly, two consumers: the runner below
|
|
129
|
+
* dispatches it, `explain` renders it — so what a reader is shown is the table the judgment
|
|
130
|
+
* actually uses, never a second opinion about it.
|
|
131
|
+
*/
|
|
132
|
+
export function assembleSessionRegistrations(spec) {
|
|
133
|
+
const { config, rootDir, covenant, transcriptPath, transcript, witness } = spec;
|
|
134
|
+
// The live transcript is the session a history declaration reads AND the one the witness
|
|
135
|
+
// reads, so erasing or forging it disables every history discipline while opening or
|
|
136
|
+
// shutting the human valve on the same file. It must NOT join this list: it lives deep
|
|
137
|
+
// under HOME, and a path entry makes every ancestor protected — which measured as an
|
|
138
|
+
// over-block refusing `cd /home/<user>`, `echo $HOME`, and every edit whose content
|
|
139
|
+
// carried a bare `~`. The dedicated `transcript-mod` registration below covers that one
|
|
140
|
+
// file instead: equality-only, never an ancestor. Ancestor destruction outside the
|
|
141
|
+
// repository is out of observation scope — the agent's own deny policy owns what no
|
|
142
|
+
// repo-scoped judge can reach.
|
|
143
|
+
const protectedPaths = normalizeProtectedPaths({
|
|
144
|
+
protectedPaths: config.protectedPaths ?? [],
|
|
145
|
+
});
|
|
146
|
+
const disciplines = config.disciplines ?? [];
|
|
147
|
+
const registrations = [
|
|
148
|
+
covenant.selfModRegistration({
|
|
149
|
+
protectedPaths,
|
|
150
|
+
mutatingToolNames: MUTATING_TOOLS,
|
|
151
|
+
witness,
|
|
152
|
+
}),
|
|
153
|
+
covenant.shellModRegistration({
|
|
154
|
+
protectedPaths,
|
|
155
|
+
shellTools: SHELL_TOOLS,
|
|
156
|
+
commandArgs: COMMAND_ARGS,
|
|
157
|
+
witness,
|
|
158
|
+
}),
|
|
159
|
+
// Routing is the matches predicate, never path mention, so the home directory cannot
|
|
160
|
+
// become a protected ancestor. No transcript in the payload means nothing to protect —
|
|
161
|
+
// the valve and every history declaration already forfeited on the same absence.
|
|
162
|
+
...(transcriptPath === undefined
|
|
163
|
+
? []
|
|
164
|
+
: [
|
|
165
|
+
covenant.transcriptModRegistration({
|
|
166
|
+
transcriptPath,
|
|
167
|
+
// The env value first, since that is what the judged shell expands `~` and
|
|
168
|
+
// `$HOME` from. `homedir()` reads the same passwd entry bash falls back to when
|
|
169
|
+
// HOME is unset, so a hook spawned without an environment (a service manager,
|
|
170
|
+
// `env -i`) keeps judging the home spellings instead of silently going
|
|
171
|
+
// absolute-only — an inert spelling closure looks identical to a passing call.
|
|
172
|
+
home: process.env.HOME ?? homedir(),
|
|
173
|
+
shellTools: SHELL_TOOLS,
|
|
174
|
+
commandArgs: COMMAND_ARGS,
|
|
175
|
+
mutatingTools: MUTATING_TOOLS,
|
|
176
|
+
witness,
|
|
177
|
+
}),
|
|
178
|
+
]),
|
|
179
|
+
...covenant.compileDisciplineRegistrations({
|
|
180
|
+
disciplines,
|
|
181
|
+
rootDir,
|
|
182
|
+
shellTools: SHELL_TOOLS,
|
|
183
|
+
commandArgs: COMMAND_ARGS,
|
|
184
|
+
readPreState: sessionPreStateReader,
|
|
185
|
+
// One PreToolUse call is the whole observation, so the derived change set is a
|
|
186
|
+
// singleton and a change-set declaration cannot be judged here — it records `skipped`,
|
|
187
|
+
// the shape the commit surface gives a history declaration.
|
|
188
|
+
observesChangeSet: false,
|
|
189
|
+
witness,
|
|
190
|
+
// The session itself, injected rather than its path: a declaration reading a
|
|
191
|
+
// `transcript` binding sees it flattened, and passing a path would leak JSONL
|
|
192
|
+
// knowledge into covenant.
|
|
193
|
+
transcript,
|
|
194
|
+
}),
|
|
195
|
+
];
|
|
196
|
+
return registrations;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* This runtime's mutating+shell roster, rewritten onto the Claude vocabulary the adapter
|
|
200
|
+
* already judges. Claude names are not keys, so an existing Write/Edit/Bash envelope
|
|
201
|
+
* passes through. A name outside the table is left alone — that is a declared limit,
|
|
202
|
+
* recorded as the adapter's funnel pass, never a parse fault. The map lives here, not in
|
|
203
|
+
* the adapter, so the adapter stays Claude-vocabulary-only.
|
|
204
|
+
*/
|
|
205
|
+
const GROK_TOOL_NAME_MAP = {
|
|
206
|
+
write: 'Write',
|
|
207
|
+
search_replace: 'Edit',
|
|
208
|
+
run_terminal_command: 'Bash',
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* Rewrite Grok tool names in a raw PreToolUse payload. Invalid JSON is left as the original
|
|
212
|
+
* string so the existing fail-closed path still runs — this function must not throw.
|
|
213
|
+
*/
|
|
214
|
+
function rewriteGrokToolNames(rawPayload) {
|
|
215
|
+
let parsed;
|
|
216
|
+
try {
|
|
217
|
+
parsed = JSON.parse(rawPayload);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return rawPayload;
|
|
221
|
+
}
|
|
222
|
+
if (!isPlainObject(parsed))
|
|
223
|
+
return rawPayload;
|
|
224
|
+
for (const key of ['tool_name', 'toolName']) {
|
|
225
|
+
const value = parsed[key];
|
|
226
|
+
if (typeof value !== 'string')
|
|
227
|
+
continue;
|
|
228
|
+
const mapped = GROK_TOOL_NAME_MAP[value];
|
|
229
|
+
if (mapped !== undefined)
|
|
230
|
+
parsed[key] = mapped;
|
|
231
|
+
}
|
|
232
|
+
return JSON.stringify(parsed);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Judge one declared tool call before it runs. Async because the dispatcher spawns covenant
|
|
236
|
+
* bodies — a synchronous runner would mean reimplementing the judge, which the
|
|
237
|
+
* single-dispatcher principle forbids.
|
|
146
238
|
*/
|
|
147
239
|
async function judgeHookCall(spec) {
|
|
148
|
-
// Env-first telemetry precedence
|
|
149
|
-
//
|
|
150
|
-
//
|
|
240
|
+
// Env-first telemetry precedence, settled BEFORE any failure branch: a config that never
|
|
241
|
+
// loads still has somewhere to write its one blocked row. The config value applies after
|
|
242
|
+
// the load succeeds.
|
|
151
243
|
//
|
|
152
244
|
// Computed INSIDE the try even though it must run first, because `join` throws on a
|
|
153
245
|
// non-string repoRoot and this function's contract is that nothing escapes it — a rejection
|
|
154
246
|
// would exit a delegator non-blocking, which is the cheapest bypass there is. A throw here
|
|
155
247
|
// leaves `telemetryPath` undefined, which the catch tolerates: there is no root to write a
|
|
156
|
-
// row under anyway
|
|
248
|
+
// row under anyway.
|
|
157
249
|
let telemetryPath;
|
|
158
250
|
try {
|
|
159
251
|
const envTelemetryPath = process.env.POLYDEUKES_TELEMETRY_PATH;
|
|
@@ -161,42 +253,25 @@ async function judgeHookCall(spec) {
|
|
|
161
253
|
spec.telemetryPath ?? envTelemetryPath ?? join(spec.repoRoot, DEFAULT_TELEMETRY_LOG_PATH);
|
|
162
254
|
// Discovery + parse + validation are the loader's job; a throw here (absent, ambiguous,
|
|
163
255
|
// unparseable, or invalid config) falls into the fail-closed catch.
|
|
164
|
-
const { config } = loadConfig(spec.repoRoot);
|
|
256
|
+
const { config } = loadConfig({ rootDir: spec.repoRoot });
|
|
165
257
|
telemetryPath =
|
|
166
258
|
spec.telemetryPath ?? envTelemetryPath ?? resolve(spec.repoRoot, config.telemetry.logPath);
|
|
167
259
|
// Settled for the rest of the happy path. The `let` above exists so the catch can still
|
|
168
260
|
// record when a failure lands before this point; a closure cannot narrow it, so the
|
|
169
261
|
// dispatch seam below takes this const instead.
|
|
170
262
|
const logPath = telemetryPath;
|
|
171
|
-
const rawPayload = spec.rawPayload ?? readFileSync(0, 'utf-8');
|
|
263
|
+
const rawPayload = rewriteGrokToolNames(spec.rawPayload ?? readFileSync(0, 'utf-8'));
|
|
172
264
|
// The transcript path travels in the raw payload only — up-translation drops it, so the
|
|
173
265
|
// adapter reads it from the string. Every failure narrows to `undefined`, which leaves
|
|
174
266
|
// the dispatcher on its `noopTranscript` default: lost evidence closes the valve rather
|
|
175
|
-
// than opening it
|
|
176
|
-
const transcriptPath = transcriptPathFromPayload(rawPayload);
|
|
177
|
-
const transcript = transcriptPath === undefined ? undefined : transcriptFromJsonlFile(transcriptPath);
|
|
178
|
-
// The live transcript is the evidence channel the context family reads AND the one the
|
|
179
|
-
// witness reads, so erasing or forging it disables every context discipline while
|
|
180
|
-
// opening or shutting the human valve on the same file. It lives outside the repository,
|
|
181
|
-
// so no config `protectedPaths` entry can reach it — and since COVENANT-07c it does NOT
|
|
182
|
-
// join this list either. A file deep under HOME makes HOME itself a protected ANCESTOR,
|
|
183
|
-
// which measured as the COVENANT-13 over-block: `cd /home/<user>` refused for two weeks,
|
|
184
|
-
// and the 07b attempt to register the home spellings alongside only widened that to
|
|
185
|
-
// `echo $HOME` and every edit whose content carried a bare `~`. Assembly knows the path
|
|
186
|
-
// AND the home value, so assembly registers a dedicated `matches` predicate over that
|
|
187
|
-
// ONE file instead (transcript-mod, below): equality-only — never an ancestor — with the
|
|
188
|
-
// `~`/`$HOME`/`${HOME}`/`~<user>` spellings closed as data, reads absolved by the
|
|
189
|
-
// read-only allowlist, and ancestor destruction outside the repository declared out of
|
|
190
|
-
// observation scope (07c §2: the agent's own deny policy owns what no repo-scoped judge
|
|
191
|
-
// can). The witness valve applies to it like any other registration.
|
|
192
|
-
const protectedPaths = normalizeProtectedPaths({
|
|
193
|
-
protectedPaths: config.protectedPaths ?? [],
|
|
194
|
-
});
|
|
267
|
+
// than opening it.
|
|
268
|
+
const transcriptPath = transcriptPathFromPayload({ rawPayload });
|
|
269
|
+
const transcript = transcriptPath === undefined ? undefined : transcriptFromJsonlFile({ path: transcriptPath });
|
|
195
270
|
// One witness predicate shared by every registration: a witness is a session-wide
|
|
196
271
|
// permission the human granted, not a per-covenant one. Absent `witness` config leaves
|
|
197
272
|
// this undefined, and no verdict can be witnessed open at all. The predicate receives
|
|
198
|
-
// the transcript as its second argument from the dispatcher
|
|
199
|
-
//
|
|
273
|
+
// the transcript as its second argument from the dispatcher, which is why the transcript
|
|
274
|
+
// is injected below rather than captured here.
|
|
200
275
|
const witness = config.witness === undefined
|
|
201
276
|
? undefined
|
|
202
277
|
: ttlWitness({
|
|
@@ -205,117 +280,55 @@ async function judgeHookCall(spec) {
|
|
|
205
280
|
// Core passes the value through verbatim, so the conversion belongs to assembly.
|
|
206
281
|
ttlMs: config.witness.ttlMinutes * 60_000,
|
|
207
282
|
});
|
|
208
|
-
// The
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
},
|
|
236
|
-
{
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
body: {
|
|
240
|
-
command: process.execPath,
|
|
241
|
-
args: [
|
|
242
|
-
shellModBody,
|
|
243
|
-
...pathArgs,
|
|
244
|
-
...SHELL_TOOLS.flatMap((tool) => ['--shell-tool', tool]),
|
|
245
|
-
...COMMAND_ARGS.flatMap((arg) => ['--command-arg', arg]),
|
|
246
|
-
],
|
|
247
|
-
},
|
|
248
|
-
witness,
|
|
249
|
-
},
|
|
250
|
-
// The transcript's own registration (COVENANT-07c). Routing is the matches predicate,
|
|
251
|
-
// never path mention, so the home directory cannot become a protected ancestor. No
|
|
252
|
-
// transcript in the payload means nothing to protect — the valve and the context
|
|
253
|
-
// family already forfeited on the same absence.
|
|
254
|
-
...(transcriptPath === undefined
|
|
255
|
-
? []
|
|
256
|
-
: [
|
|
257
|
-
transcriptModRegistration({
|
|
258
|
-
transcriptPath,
|
|
259
|
-
// The env value first, since that is what the judged shell expands `~` and
|
|
260
|
-
// `$HOME` from. `homedir()` reads the same passwd entry bash falls back to when
|
|
261
|
-
// HOME is unset, so a hook spawned without an environment (a service manager,
|
|
262
|
-
// `env -i`) keeps judging the home spellings instead of silently going
|
|
263
|
-
// absolute-only — an inert spelling closure looks identical to a passing call.
|
|
264
|
-
home: process.env.HOME ?? homedir(),
|
|
265
|
-
bodyCommand: process.execPath,
|
|
266
|
-
bodyModulePath: provenBodyPath(covenantDist, 'transcript-mod-body.js'),
|
|
267
|
-
shellTools: SHELL_TOOLS,
|
|
268
|
-
commandArgs: COMMAND_ARGS,
|
|
269
|
-
mutatingTools: MUTATING_TOOLS,
|
|
270
|
-
witness,
|
|
271
|
-
}),
|
|
272
|
-
]),
|
|
273
|
-
// The body path is passed as a thunk, so the proof fires only where the compiler
|
|
274
|
-
// actually composes a body. Entry count cannot stand in for that: an entry may compile
|
|
275
|
-
// to a body-less skip (a `requirePrecedent` one whenever no transcript came with the
|
|
276
|
-
// payload), and the compiler appends the body-less `shell-unjudgeable` backstop even
|
|
277
|
-
// for zero entries — gating the call itself would drop that record and turn an
|
|
278
|
-
// uncomputable shell write back into a silent pass, undoing COVENANT-10b.
|
|
279
|
-
...compileDisciplineRegistrations({
|
|
280
|
-
disciplines,
|
|
281
|
-
rootDir: spec.repoRoot,
|
|
282
|
-
bodyCommand: process.execPath,
|
|
283
|
-
bodyModulePath: () => provenBodyPath(covenantDist, 'discipline-body.js'),
|
|
284
|
-
shellTools: SHELL_TOOLS,
|
|
285
|
-
commandArgs: COMMAND_ARGS,
|
|
286
|
-
witness,
|
|
287
|
-
// Context-family evidence is evaluated here, at assembly: a spawned body cannot hold
|
|
288
|
-
// a transcript, and passing a path would leak JSONL knowledge into covenant
|
|
289
|
-
// (COVENANT-13 §4.4). The adapter brings the evaluator for its own `subagent`/`tool`
|
|
290
|
-
// vocabulary; core owns `command`, which the compiler judges directly.
|
|
291
|
-
transcript,
|
|
292
|
-
evaluatePrecedent,
|
|
293
|
-
}),
|
|
294
|
-
];
|
|
295
|
-
// This assembly is versioned with the umbrella; the covenant dist it composes against is
|
|
296
|
-
// resolved from the installation graph, so a workspace nobody rebuilt pairs a new
|
|
297
|
-
// assembly with an old compiler — and an old compiler stores the body-path thunk itself
|
|
298
|
-
// where a string belongs. `spawn` does not reject a non-string argv entry — it
|
|
299
|
-
// stringifies it — so the judge would be spawned on the thunk's own source text, exit 1,
|
|
300
|
-
// and be recorded as a VERDICT under a discipline's label. Assert the shape and let the
|
|
301
|
-
// fail-closed catch answer instead.
|
|
302
|
-
for (const registration of registrations) {
|
|
303
|
-
if (registration.body !== undefined && typeof registration.body.args?.[0] !== 'string') {
|
|
304
|
-
throw new Error(`covenant dist predates the lazy body-path convention (registration '${registration.label}') — run 'pnpm build'`);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
283
|
+
// The judges are the covenant package's built barrel — resolved through the real
|
|
284
|
+
// package (never a test alias), so the session surface runs the same judges the commit
|
|
285
|
+
// surface does. An injected directory overrides that resolution, which is how a fixture
|
|
286
|
+
// reaches a dist that real Node resolution would never land on. Awaited HERE, before
|
|
287
|
+
// any registration is composed: a dist the barrel cannot load throws now, into the
|
|
288
|
+
// fail-closed catch, instead of leaving a half-judged table behind.
|
|
289
|
+
const covenantDist = spec.covenantDist ?? resolveCovenantDist();
|
|
290
|
+
const covenant = await loadCovenantModule(covenantDist);
|
|
291
|
+
// Assembled HERE, outside the dispatch seam: a judge takes its call set as an argument,
|
|
292
|
+
// so assembly needs no payload, and an assembly throw belongs to this function's own
|
|
293
|
+
// fail-closed catch — `hook` label, `covenant hook failed closed:` on stderr. Composed
|
|
294
|
+
// inside the dispatch closure it would land in `runAdapterPath`'s catch instead, which
|
|
295
|
+
// records the adapter's label and says nothing about what broke.
|
|
296
|
+
const registrations = assembleSessionRegistrations({
|
|
297
|
+
config,
|
|
298
|
+
rootDir: spec.repoRoot,
|
|
299
|
+
covenant,
|
|
300
|
+
transcriptPath,
|
|
301
|
+
transcript,
|
|
302
|
+
witness,
|
|
303
|
+
});
|
|
304
|
+
// The world axis: files read from disk under the repository root, channels read beside
|
|
305
|
+
// the session's transcript. The disk is the pre-edit state on this surface; the rule
|
|
306
|
+
// that the judged change's own `post` overrides it belongs to the judge, so the root
|
|
307
|
+
// supplies what it read and nothing more. No `changes` list either — one PreToolUse call
|
|
308
|
+
// is the whole observation, and the judge derives that set from the input.
|
|
309
|
+
const { files, channels } = covenant.supplySources({
|
|
310
|
+
plan: covenant.planSources({ registrations }),
|
|
311
|
+
read: sessionSourceReader({ repoRoot: spec.repoRoot }),
|
|
312
|
+
readChannel: sessionChannelReader({ transcriptPath }),
|
|
313
|
+
});
|
|
307
314
|
return await runAdapterPath({
|
|
308
315
|
rawPayload,
|
|
309
316
|
telemetryPath: logPath,
|
|
310
|
-
dispatch: (stdinPayload) => dispatchCovenants({
|
|
317
|
+
dispatch: (stdinPayload) => covenant.dispatchCovenants({
|
|
318
|
+
stdinPayload,
|
|
319
|
+
registrations,
|
|
320
|
+
telemetryPath: logPath,
|
|
321
|
+
transcript,
|
|
322
|
+
world: { files, channels },
|
|
323
|
+
}),
|
|
311
324
|
});
|
|
312
325
|
}
|
|
313
326
|
catch (error) {
|
|
314
327
|
process.stderr.write(`covenant hook failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
315
328
|
// Honor the one-call-one-record invariant with a blocked record under the assembly's own
|
|
316
|
-
// label
|
|
317
|
-
//
|
|
318
|
-
//
|
|
329
|
+
// label — never a judge's, since no judge answered. `undefined` means the failure landed
|
|
330
|
+
// before a path could even be composed (a non-string repoRoot), where there is nowhere
|
|
331
|
+
// to write and nothing to attribute the row to.
|
|
319
332
|
if (telemetryPath !== undefined) {
|
|
320
333
|
appendRecordFailOpen(telemetryPath, { event: 'blocked', label: 'hook', subject: '-' });
|
|
321
334
|
}
|
|
@@ -323,8 +336,7 @@ async function judgeHookCall(spec) {
|
|
|
323
336
|
}
|
|
324
337
|
}
|
|
325
338
|
/**
|
|
326
|
-
* The session-surface entry point: the post-hoc state comparison wrapped around the judgment
|
|
327
|
-
* (COVENANT-14 §2-f).
|
|
339
|
+
* The session-surface entry point: the post-hoc state comparison wrapped around the judgment.
|
|
328
340
|
*
|
|
329
341
|
* The comparison sits OUTSIDE {@link judgeHookCall}'s fail-closed try on both ends. Inside
|
|
330
342
|
* it, a comparison failure would become a blocked call — the opposite of a mechanism whose
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `polydeukes/claude-code` — the session surface's entry point.
|
|
3
|
+
*
|
|
4
|
+
* The delegator a consumer project's PreToolUse hook imports: one verb and its spec type.
|
|
5
|
+
*/
|
|
6
|
+
export { type ClaudeCodeHookOutcome, type ClaudeCodeHookSpec, runClaudeCodeHook, } from './claude-code-hook.ts';
|
package/dist/covenant-check.d.ts
CHANGED
|
@@ -1,58 +1,72 @@
|
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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).
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
import { type Observation } from '@polydeukes/adapter-git';
|
|
14
|
+
import type { CovenantRegistration } from '@polydeukes/covenant';
|
|
15
|
+
import { type CovenantModule } from './covenant-module.ts';
|
|
16
|
+
import { loadConfig } from './load-config.ts';
|
|
17
|
+
/**
|
|
18
|
+
* Which observation of the commit surface a run judges. Only the collector differs between
|
|
19
|
+
* them; the IR, the assembly, and the dispatcher are one path.
|
|
23
20
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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).
|
|
21
|
+
* The adapter that owns the git grammar owns the type: its supply body reads a path the way
|
|
22
|
+
* each observation sees the tree, and this root names the same fact for its callers.
|
|
29
23
|
*/
|
|
30
|
-
|
|
24
|
+
export type CheckDomain = Observation;
|
|
25
|
+
/** {@link runCovenantCheck} result — the exit code the check process leaves with. */
|
|
26
|
+
export type CovenantCheckOutcome = {
|
|
27
|
+
exitCode: 0 | 2;
|
|
28
|
+
};
|
|
29
|
+
/** `runCovenantCheck` input. */
|
|
31
30
|
export type CovenantCheckSpec = {
|
|
32
31
|
/** Repository root — config discovery and staged collection both anchor here. */
|
|
33
32
|
repoRoot: string;
|
|
34
33
|
/**
|
|
35
34
|
* Overrides where telemetry is written (tests and assembly injection) — the first term
|
|
36
35
|
* of the precedence, ahead of the config's `telemetry.logPath` and of the default this
|
|
37
|
-
* runner settles before the config loads
|
|
38
|
-
* apply in that order.
|
|
36
|
+
* runner settles before the config loads. Absent, both of those apply in that order.
|
|
39
37
|
*/
|
|
40
38
|
telemetryPath?: string;
|
|
41
39
|
/** Overrides the resolved covenant dist directory (tests and assembly injection). */
|
|
42
40
|
covenantDist?: string;
|
|
43
41
|
/**
|
|
44
42
|
* TTY valve seam: writes the given prompt and returns the line a human typed, or null
|
|
45
|
-
* for no input. ABSENT means a non-TTY environment — the valve never opens
|
|
46
|
-
* human-only
|
|
43
|
+
* for no input. ABSENT means a non-TTY environment — the valve never opens, which is
|
|
44
|
+
* what keeps it human-only.
|
|
47
45
|
*/
|
|
48
46
|
ttyPrompt?: (prompt: string) => string | null;
|
|
47
|
+
/** Which observation to judge. ABSENT means `staged`. */
|
|
48
|
+
domain?: CheckDomain;
|
|
49
|
+
};
|
|
50
|
+
/** {@link assembleCommitRegistrations} input — what the commit surface's assembly needs. */
|
|
51
|
+
export type CommitAssemblySpec = {
|
|
52
|
+
config: ReturnType<typeof loadConfig>['config'];
|
|
53
|
+
rootDir: string;
|
|
54
|
+
/**
|
|
55
|
+
* The covenant surface the registrations are built from — the module the caller loaded
|
|
56
|
+
* from the resolved dist, so what judges a change is what that dist carries, and what
|
|
57
|
+
* `explain` renders is what would judge it.
|
|
58
|
+
*/
|
|
59
|
+
covenant: CovenantModule;
|
|
60
|
+
witness?: CovenantRegistration['witness'];
|
|
49
61
|
};
|
|
50
62
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* a synchronous runner would mean reimplementing the judge, which the single-dispatcher
|
|
54
|
-
* principle forbids.
|
|
63
|
+
* The commit surface's registration set — one assembly that the runner dispatches and
|
|
64
|
+
* `explain` renders.
|
|
55
65
|
*/
|
|
56
|
-
export declare function
|
|
57
|
-
|
|
58
|
-
|
|
66
|
+
export declare function assembleCommitRegistrations(spec: CommitAssemblySpec): CovenantRegistration[];
|
|
67
|
+
/**
|
|
68
|
+
* Judge one observation of `repoRoot` exactly as the session surface would — the staged
|
|
69
|
+
* diff by default, the working tree or a ref range on request. Async because the dispatcher
|
|
70
|
+
* spawns covenant bodies. An empty domain is an explicit pass: nothing to judge, no records.
|
|
71
|
+
*/
|
|
72
|
+
export declare function runCovenantCheck(spec: CovenantCheckSpec): Promise<CovenantCheckOutcome>;
|