polydeukes 0.0.1 → 0.4.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/LICENSE +21 -0
- package/README.ko.md +73 -0
- package/README.md +67 -12
- package/dist/bin.d.ts +15 -0
- package/dist/bin.js +138 -0
- package/dist/claude-code-hook.d.ts +56 -0
- package/dist/claude-code-hook.js +360 -0
- package/dist/covenant-check.d.ts +58 -0
- package/dist/covenant-check.js +262 -0
- package/dist/docs/configuration.md +95 -0
- package/dist/docs/installation.md +211 -0
- package/dist/docs/reference/adapter-claude-code.md +82 -0
- package/dist/docs/reference/adapter-git.md +87 -0
- package/dist/docs/reference/configuration.md +294 -0
- package/dist/docs/reference/core.md +111 -0
- package/dist/docs/reference/covenant.md +108 -0
- package/dist/docs/reference/polydeukes.md +215 -0
- package/dist/docs/troubleshooting.md +160 -0
- package/dist/docs-query.d.ts +46 -0
- package/dist/docs-query.js +138 -0
- package/dist/index.d.ts +23 -4
- package/dist/index.js +22 -4
- package/dist/init-claude-code.d.ts +39 -0
- package/dist/init-claude-code.js +255 -0
- package/dist/load-config.d.ts +43 -0
- package/dist/load-config.js +90 -0
- package/dist/scaffold-project.d.ts +28 -0
- package/dist/scaffold-project.js +137 -0
- package/dist/schema/polydeukes.schema.json +203 -0
- package/package.json +31 -11
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `runClaudeCodeHook` — the assembled session-surface judgment runner (DIST-01 §3-c).
|
|
3
|
+
*
|
|
4
|
+
* The session counterpart of {@link runCovenantCheck}, and the one place where the Claude
|
|
5
|
+
* Code adapter (tool vocabulary, up-translation) and the covenant package (dispatcher +
|
|
6
|
+
* judge bodies) meet. Packages stay one-way — each depends only on core — so their
|
|
7
|
+
* composition lives here, in the umbrella, and the repository's PreToolUse hook shrinks to
|
|
8
|
+
* a delegator that calls this function. That is what makes the session surface installable:
|
|
9
|
+
* a consumer registers a hook that resolves this package instead of copying assembly.
|
|
10
|
+
*
|
|
11
|
+
* Wiring shape: COVENANT-03 §4.4 + COVENANT-04d §4.5 registrations consumed through
|
|
12
|
+
* ADAPTER-03 §4.1 `runAdapterPath`, with `dispatchCovenants` bound to the injected dispatch
|
|
13
|
+
* seam. The protection-policy data (protectedPaths / disciplines / witness) is read from the
|
|
14
|
+
* root data config through {@link loadConfig} (CONFIG-03), which also attaches the config
|
|
15
|
+
* file to its own surface.
|
|
16
|
+
*
|
|
17
|
+
* The valve is the TTL witness (COVENANT-06, moved behind the verdict by COVENANT-17)
|
|
18
|
+
* judged over the JSONL transcript provider (ADAPTER-04). The judge body always spawns, and
|
|
19
|
+
* only an outcome that translated to blocked consults the witness — `witnessed` rows are
|
|
20
|
+
* would-block only. Its defence is provenance rather than secrecy: only a real human
|
|
21
|
+
* utterance carries the transcript marking `findUserMessages()` admits.
|
|
22
|
+
*
|
|
23
|
+
* fail-closed: ANY failure — an unbuilt judge body, an unreadable stdin, a missing or
|
|
24
|
+
* invalid config file — resolves to `{ exitCode: 2 }` with one `blocked` record under the
|
|
25
|
+
* `hook` label. Nothing throws: an uncaught rejection would exit the delegator non-blocking,
|
|
26
|
+
* the cheapest bypass vector there is. Recovery from an unbuilt clone is `pnpm build` (it
|
|
27
|
+
* mentions no protected path, so it is never blocked).
|
|
28
|
+
*/
|
|
29
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
30
|
+
import { createRequire } from 'node:module';
|
|
31
|
+
import { homedir } from 'node:os';
|
|
32
|
+
import { dirname, join, resolve } from 'node:path';
|
|
33
|
+
import { COMMAND_ARGS, evaluatePrecedent, MUTATING_TOOLS, runAdapterPath, SHELL_TOOLS, transcriptFromJsonlFile, transcriptPathFromPayload, } from '@polydeukes/adapter-claude-code';
|
|
34
|
+
import { appendRecordFailOpen, DEFAULT_TELEMETRY_LOG_PATH, normalizeProtectedPaths, readRecords, } from '@polydeukes/core';
|
|
35
|
+
import { compileDisciplineRegistrations, dispatchCovenants, findUnattributed, readBaseline, snapshotBaseline, transcriptModRegistration, ttlWitness, writeBaseline, } from '@polydeukes/covenant';
|
|
36
|
+
import { loadConfig } from './load-config.js';
|
|
37
|
+
/**
|
|
38
|
+
* Compose a judge body path and prove it exists (CONFIG-06b §4.2). A body module that was
|
|
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). */
|
|
52
|
+
const BASELINE_LABEL = 'baseline';
|
|
53
|
+
/**
|
|
54
|
+
* Compare the protected entries' on-disk state against the stored baseline and record what
|
|
55
|
+
* moved with no judgment explaining it (COVENANT-14 §2-f).
|
|
56
|
+
*
|
|
57
|
+
* Runs at hook call START, before this call's own judgment rows land, so the window it reads
|
|
58
|
+
* is the one the previous comparison left open. Returns the record count as of right now —
|
|
59
|
+
* where the NEXT window opens, which {@link updateBaseline} persists at call end.
|
|
60
|
+
*
|
|
61
|
+
* The comparison records, it never blocks: no row it writes and no failure it hits changes
|
|
62
|
+
* a verdict or an exit code, which is why every caller keeps it outside the judgment path.
|
|
63
|
+
*/
|
|
64
|
+
function compareBaseline(spec) {
|
|
65
|
+
const baselinePath = join(spec.repoRoot, '.polydeukes', 'baseline.json');
|
|
66
|
+
// Read before any row of this comparison lands, so the rows this call is about to write
|
|
67
|
+
// cannot fall inside the window they would then explain away.
|
|
68
|
+
const { records } = readRecords(spec.telemetryPath);
|
|
69
|
+
const stored = readBaseline(baselinePath);
|
|
70
|
+
if (stored === null) {
|
|
71
|
+
// Absence and corruption are the same signal (§2-e). The baseline file is deliberately
|
|
72
|
+
// NOT on the protection list — protecting it would need a comparison of its own — so its
|
|
73
|
+
// disappearance has to stay legible in the log instead.
|
|
74
|
+
appendRecordFailOpen(spec.telemetryPath, {
|
|
75
|
+
event: 'unattributed',
|
|
76
|
+
label: BASELINE_LABEL,
|
|
77
|
+
subject: baselinePath,
|
|
78
|
+
});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const changed = findUnattributed({
|
|
82
|
+
previous: stored.entries,
|
|
83
|
+
current: snapshotBaseline({ rootDir: spec.repoRoot, entries: spec.entries }),
|
|
84
|
+
records,
|
|
85
|
+
// The cut travels with the hashes it belongs to, from the one read above. Rows older
|
|
86
|
+
// than it were already spent explaining the state that snapshot recorded.
|
|
87
|
+
cutAt: stored.cutAt,
|
|
88
|
+
});
|
|
89
|
+
// One row per changed entry — an aggregate row could not say WHICH gate definition moved.
|
|
90
|
+
for (const entry of changed) {
|
|
91
|
+
appendRecordFailOpen(spec.telemetryPath, {
|
|
92
|
+
event: 'unattributed',
|
|
93
|
+
label: BASELINE_LABEL,
|
|
94
|
+
subject: entry,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Re-establish the baseline at hook call END (COVENANT-14 §5).
|
|
100
|
+
*
|
|
101
|
+
* At call end rather than right after the comparison: refreshing at comparison time would
|
|
102
|
+
* miss whatever this call's own judged writes changed, leaving detection permanently one
|
|
103
|
+
* call behind.
|
|
104
|
+
*
|
|
105
|
+
* The cut is stamped HERE, beside the snapshot, not at the comparison that opened the call.
|
|
106
|
+
* Both describe the same instant — everything this call did is already folded into the
|
|
107
|
+
* hashes — so the rows explaining it belong before the cut. Stamping the earlier instant
|
|
108
|
+
* instead would re-admit this call's own judgment rows into the next window, where they
|
|
109
|
+
* would attribute a change they had nothing to do with: a call that merely MENTIONED a
|
|
110
|
+
* protected entry would then absolve any tamper that followed it.
|
|
111
|
+
*/
|
|
112
|
+
function updateBaseline(spec) {
|
|
113
|
+
const dotDir = join(spec.repoRoot, '.polydeukes');
|
|
114
|
+
mkdirSync(dotDir, { recursive: true });
|
|
115
|
+
writeBaseline(join(dotDir, 'baseline.json'), snapshotBaseline({ rootDir: spec.repoRoot, entries: spec.entries }), new Date().toISOString());
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Where the comparison writes and what it observes, or `undefined` (COVENANT-14 §6).
|
|
119
|
+
*
|
|
120
|
+
* The domain is derived from config rather than enumerated here, and the telemetry path is
|
|
121
|
+
* resolved by the same precedence the judgment uses so both land in one log. A config that
|
|
122
|
+
* does not load leaves NO domain, so there is nothing to compare and nothing to re-establish
|
|
123
|
+
* — the judgment path already answers that failure fail-closed, and a comparison row on top
|
|
124
|
+
* of it would report the same absence twice under a label that judges nothing.
|
|
125
|
+
*/
|
|
126
|
+
function comparisonSpec(spec) {
|
|
127
|
+
let config;
|
|
128
|
+
try {
|
|
129
|
+
config = loadConfig(spec.repoRoot).config;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
repoRoot: spec.repoRoot,
|
|
136
|
+
telemetryPath: spec.telemetryPath ??
|
|
137
|
+
process.env.POLYDEUKES_TELEMETRY_PATH ??
|
|
138
|
+
resolve(spec.repoRoot, config.telemetry.logPath),
|
|
139
|
+
entries: normalizeProtectedPaths({ protectedPaths: config.protectedPaths }),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Judge one declared tool call before it runs (DIST-01 §3-c). Async because the dispatcher
|
|
144
|
+
* spawns covenant bodies (CORE-01) — a synchronous runner would mean reimplementing the
|
|
145
|
+
* judge, which the single-dispatcher principle forbids.
|
|
146
|
+
*/
|
|
147
|
+
async function judgeHookCall(spec) {
|
|
148
|
+
// Env-first telemetry precedence (E2E contract), settled BEFORE any failure branch: a
|
|
149
|
+
// config that never loads still has somewhere to write its one blocked row. The config
|
|
150
|
+
// value applies after the load succeeds.
|
|
151
|
+
//
|
|
152
|
+
// Computed INSIDE the try even though it must run first, because `join` throws on a
|
|
153
|
+
// non-string repoRoot and this function's contract is that nothing escapes it — a rejection
|
|
154
|
+
// would exit a delegator non-blocking, which is the cheapest bypass there is. A throw here
|
|
155
|
+
// leaves `telemetryPath` undefined, which the catch tolerates: there is no root to write a
|
|
156
|
+
// row under anyway (PR #46 review).
|
|
157
|
+
let telemetryPath;
|
|
158
|
+
try {
|
|
159
|
+
const envTelemetryPath = process.env.POLYDEUKES_TELEMETRY_PATH;
|
|
160
|
+
telemetryPath =
|
|
161
|
+
spec.telemetryPath ?? envTelemetryPath ?? join(spec.repoRoot, DEFAULT_TELEMETRY_LOG_PATH);
|
|
162
|
+
// Discovery + parse + validation are the loader's job; a throw here (absent, ambiguous,
|
|
163
|
+
// unparseable, or invalid config) falls into the fail-closed catch.
|
|
164
|
+
const { config } = loadConfig(spec.repoRoot);
|
|
165
|
+
telemetryPath =
|
|
166
|
+
spec.telemetryPath ?? envTelemetryPath ?? resolve(spec.repoRoot, config.telemetry.logPath);
|
|
167
|
+
// Settled for the rest of the happy path. The `let` above exists so the catch can still
|
|
168
|
+
// record when a failure lands before this point; a closure cannot narrow it, so the
|
|
169
|
+
// dispatch seam below takes this const instead.
|
|
170
|
+
const logPath = telemetryPath;
|
|
171
|
+
const rawPayload = spec.rawPayload ?? readFileSync(0, 'utf-8');
|
|
172
|
+
// The transcript path travels in the raw payload only — up-translation drops it, so the
|
|
173
|
+
// adapter reads it from the string. Every failure narrows to `undefined`, which leaves
|
|
174
|
+
// the dispatcher on its `noopTranscript` default: lost evidence closes the valve rather
|
|
175
|
+
// than opening it (ADAPTER-04 §4.4).
|
|
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
|
+
});
|
|
195
|
+
// One witness predicate shared by every registration: a witness is a session-wide
|
|
196
|
+
// permission the human granted, not a per-covenant one. Absent `witness` config leaves
|
|
197
|
+
// this undefined, and no verdict can be witnessed open at all. The predicate receives
|
|
198
|
+
// the transcript as its second argument from the dispatcher (CORE-04 seam), which is why
|
|
199
|
+
// the transcript is injected below rather than captured here.
|
|
200
|
+
const witness = config.witness === undefined
|
|
201
|
+
? undefined
|
|
202
|
+
: ttlWitness({
|
|
203
|
+
token: config.witness.token,
|
|
204
|
+
// Minutes are the human-facing unit in config; the predicate takes milliseconds.
|
|
205
|
+
// Core passes the value through verbatim, so the conversion belongs to assembly.
|
|
206
|
+
ttlMs: config.witness.ttlMinutes * 60_000,
|
|
207
|
+
});
|
|
208
|
+
// The judge bodies are the covenant package's dist executables — resolved through the
|
|
209
|
+
// real package (never a test alias), so the session surface spawns the same judges the
|
|
210
|
+
// commit surface does. An injected directory overrides that resolution: `createRequire`
|
|
211
|
+
// is real Node resolution and always lands on the real build, which no fixture tree can
|
|
212
|
+
// take a body away from.
|
|
213
|
+
const covenantDist = spec.covenantDist ?? dirname(createRequire(import.meta.url).resolve('@polydeukes/covenant'));
|
|
214
|
+
// Only the two unconditional registrations compose their paths here. The transcript-mod
|
|
215
|
+
// and discipline bodies are composed inside the conditions that decide whether their
|
|
216
|
+
// registrations exist at all — proving a body this run will never spawn would close a
|
|
217
|
+
// call over a file it was never going to use (CONFIG-06b §4.2 corollary).
|
|
218
|
+
const selfModBody = provenBodyPath(covenantDist, 'self-mod-body.js');
|
|
219
|
+
const shellModBody = provenBodyPath(covenantDist, 'shell-mod-body.js');
|
|
220
|
+
const disciplines = config.disciplines ?? [];
|
|
221
|
+
const pathArgs = protectedPaths.flatMap((path) => ['--protected-path', path]);
|
|
222
|
+
const registrations = [
|
|
223
|
+
{
|
|
224
|
+
label: 'self-mod',
|
|
225
|
+
protectedPaths,
|
|
226
|
+
body: {
|
|
227
|
+
command: process.execPath,
|
|
228
|
+
args: [
|
|
229
|
+
selfModBody,
|
|
230
|
+
...pathArgs,
|
|
231
|
+
...MUTATING_TOOLS.flatMap((tool) => ['--mutating-tool', tool]),
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
witness,
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
label: 'shell-mod',
|
|
238
|
+
protectedPaths,
|
|
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
|
+
}
|
|
307
|
+
return await runAdapterPath({
|
|
308
|
+
rawPayload,
|
|
309
|
+
telemetryPath: logPath,
|
|
310
|
+
dispatch: (stdinPayload) => dispatchCovenants({ stdinPayload, registrations, telemetryPath: logPath, transcript }),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
process.stderr.write(`covenant hook failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
315
|
+
// Honor the one-call-one-record invariant with a blocked record under the assembly's own
|
|
316
|
+
// label (COVENANT-07 §4.3) — never a judge's, since no judge answered. `undefined` means
|
|
317
|
+
// the failure landed before a path could even be composed (a non-string repoRoot), where
|
|
318
|
+
// there is nowhere to write and nothing to attribute the row to.
|
|
319
|
+
if (telemetryPath !== undefined) {
|
|
320
|
+
appendRecordFailOpen(telemetryPath, { event: 'blocked', label: 'hook', subject: '-' });
|
|
321
|
+
}
|
|
322
|
+
return { exitCode: 2 };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The session-surface entry point: the post-hoc state comparison wrapped around the judgment
|
|
327
|
+
* (COVENANT-14 §2-f).
|
|
328
|
+
*
|
|
329
|
+
* The comparison sits OUTSIDE {@link judgeHookCall}'s fail-closed try on both ends. Inside
|
|
330
|
+
* it, a comparison failure would become a blocked call — the opposite of a mechanism whose
|
|
331
|
+
* whole purpose is to record rather than stop — so each side carries its own catch and
|
|
332
|
+
* neither can reach the verdict. Observation is fail-open, the direction
|
|
333
|
+
* `appendRecordFailOpen` already established: the worst outcome is a missing datum.
|
|
334
|
+
*
|
|
335
|
+
* Order is the contract. The comparison runs first, so it reads the window the previous call
|
|
336
|
+
* left and its rows land ahead of this call's judgment; the re-establishment runs last, so
|
|
337
|
+
* this call's own judged writes are folded in rather than alarmed on next time.
|
|
338
|
+
*/
|
|
339
|
+
export async function runClaudeCodeHook(spec) {
|
|
340
|
+
let comparison;
|
|
341
|
+
try {
|
|
342
|
+
comparison = comparisonSpec(spec);
|
|
343
|
+
if (comparison !== undefined) {
|
|
344
|
+
compareBaseline(comparison);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
// fail-open: a comparison that could not run leaves the judgment exactly as it was.
|
|
349
|
+
}
|
|
350
|
+
const result = await judgeHookCall(spec);
|
|
351
|
+
try {
|
|
352
|
+
if (comparison !== undefined) {
|
|
353
|
+
updateBaseline(comparison);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
// fail-open: an unwritable baseline costs the next call's detection, never this verdict.
|
|
358
|
+
}
|
|
359
|
+
return result;
|
|
360
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pdks covenant check` — the assembled commit-surface judgment runner (ADAPTER-git §4.3).
|
|
3
|
+
*
|
|
4
|
+
* This is the commit-surface counterpart of the session hook's composition root: the one
|
|
5
|
+
* umbrella-owned place where the git adapter (staged-diff vocabulary), the covenant
|
|
6
|
+
* dispatcher, and the config loader meet. Assembly order mirrors the session hook —
|
|
7
|
+
* loadConfig → normalizeProtectedPaths → collect/translate → dispatchCovenants — and the
|
|
8
|
+
* judge bodies it spawns are the very same covenant dist executables, so a staged change
|
|
9
|
+
* receives the same verdict a session tool call would (AC-4 same-judge).
|
|
10
|
+
*
|
|
11
|
+
* Each staged change is dispatched as its own single-change input: one staged file is
|
|
12
|
+
* the commit surface's analogue of one session tool call, so telemetry stays N:N (AC-6)
|
|
13
|
+
* and `gain` reads a per-file subject rather than one opaque batch line.
|
|
14
|
+
*
|
|
15
|
+
* The valve is a TTY prompt (PRD §4.4 decision A): the injected `ttyPrompt` seam returns
|
|
16
|
+
* the line a human typed at the terminal, compared against the config witness token in
|
|
17
|
+
* FULL (COVENANT-15 — substring acceptance is forbidden). The seam's absence models a
|
|
18
|
+
* non-interactive environment (CI, an AI-spawned git commit): no prompt, no witness —
|
|
19
|
+
* the valve is structurally reachable only by a human at a terminal, which is the
|
|
20
|
+
* commit-surface translation of "only a human utterance opens the session valve". The
|
|
21
|
+
* answer is cached so one commit prompts at most once, and nothing is ever persisted —
|
|
22
|
+
* a state file would be an agent-forgeable surface (PRD §7).
|
|
23
|
+
*
|
|
24
|
+
* fail-closed: a missing/invalid config, an unbuilt judge body, or a collector failure
|
|
25
|
+
* exits 2 with one blocked record. The telemetry path is settled before the first failure
|
|
26
|
+
* branch can be taken (ADAPTER-git-b §4.1), so the record has somewhere to land even when
|
|
27
|
+
* the config that names its path never loaded. An empty staging area is an explicit pass
|
|
28
|
+
* (nothing to judge — the dispatcher precedent of zero matches, zero records).
|
|
29
|
+
*/
|
|
30
|
+
/** `runCovenantCheck` input (ADAPTER-git §4.3 — the contract covenant-check tests pin). */
|
|
31
|
+
export type CovenantCheckSpec = {
|
|
32
|
+
/** Repository root — config discovery and staged collection both anchor here. */
|
|
33
|
+
repoRoot: string;
|
|
34
|
+
/**
|
|
35
|
+
* Overrides where telemetry is written (tests and assembly injection) — the first term
|
|
36
|
+
* of the precedence, ahead of the config's `telemetry.logPath` and of the default this
|
|
37
|
+
* runner settles before the config loads (ADAPTER-git-b §4.1). Absent, both of those
|
|
38
|
+
* apply in that order.
|
|
39
|
+
*/
|
|
40
|
+
telemetryPath?: string;
|
|
41
|
+
/** Overrides the resolved covenant dist directory (tests and assembly injection). */
|
|
42
|
+
covenantDist?: string;
|
|
43
|
+
/**
|
|
44
|
+
* 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 (AC-3
|
|
46
|
+
* human-only arming).
|
|
47
|
+
*/
|
|
48
|
+
ttyPrompt?: (prompt: string) => string | null;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Judge the staged changes of `repoRoot` exactly as the session surface would
|
|
52
|
+
* (ADAPTER-git §4.3). Async because the dispatcher spawns covenant bodies (CORE-01) —
|
|
53
|
+
* a synchronous runner would mean reimplementing the judge, which the single-dispatcher
|
|
54
|
+
* principle forbids.
|
|
55
|
+
*/
|
|
56
|
+
export declare function runCovenantCheck(spec: CovenantCheckSpec): Promise<{
|
|
57
|
+
exitCode: 0 | 2;
|
|
58
|
+
}>;
|