polydeukes 0.3.0 → 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/dist/claude-code-hook.d.ts +12 -3
- package/dist/claude-code-hook.js +132 -5
- package/dist/covenant-check.d.ts +10 -4
- package/dist/covenant-check.js +42 -18
- package/dist/docs/configuration.md +28 -294
- package/dist/docs/installation.md +4 -1
- package/dist/docs/reference/adapter-git.md +1 -1
- package/dist/docs/reference/configuration.md +294 -0
- package/dist/docs/reference/core.md +4 -4
- package/dist/docs/reference/covenant.md +12 -4
- package/dist/docs/reference/polydeukes.md +8 -3
- package/dist/docs/troubleshooting.md +9 -1
- package/dist/docs-query.js +8 -8
- package/dist/load-config.d.ts +3 -2
- package/dist/load-config.js +10 -3
- package/dist/scaffold-project.d.ts +0 -9
- package/dist/scaffold-project.js +23 -9
- package/dist/schema/polydeukes.schema.json +203 -0
- package/package.json +8 -7
|
@@ -38,9 +38,18 @@ export type ClaudeCodeHookSpec = {
|
|
|
38
38
|
covenantDist?: string;
|
|
39
39
|
};
|
|
40
40
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
41
|
+
* The session-surface entry point: the post-hoc state comparison wrapped around the judgment
|
|
42
|
+
* (COVENANT-14 §2-f).
|
|
43
|
+
*
|
|
44
|
+
* The comparison sits OUTSIDE {@link judgeHookCall}'s fail-closed try on both ends. Inside
|
|
45
|
+
* it, a comparison failure would become a blocked call — the opposite of a mechanism whose
|
|
46
|
+
* whole purpose is to record rather than stop — so each side carries its own catch and
|
|
47
|
+
* neither can reach the verdict. Observation is fail-open, the direction
|
|
48
|
+
* `appendRecordFailOpen` already established: the worst outcome is a missing datum.
|
|
49
|
+
*
|
|
50
|
+
* Order is the contract. The comparison runs first, so it reads the window the previous call
|
|
51
|
+
* left and its rows land ahead of this call's judgment; the re-establishment runs last, so
|
|
52
|
+
* this call's own judged writes are folded in rather than alarmed on next time.
|
|
44
53
|
*/
|
|
45
54
|
export declare function runClaudeCodeHook(spec: ClaudeCodeHookSpec): Promise<{
|
|
46
55
|
exitCode: 0 | 2;
|
package/dist/claude-code-hook.js
CHANGED
|
@@ -26,13 +26,13 @@
|
|
|
26
26
|
* the cheapest bypass vector there is. Recovery from an unbuilt clone is `pnpm build` (it
|
|
27
27
|
* mentions no protected path, so it is never blocked).
|
|
28
28
|
*/
|
|
29
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
29
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
30
30
|
import { createRequire } from 'node:module';
|
|
31
31
|
import { homedir } from 'node:os';
|
|
32
32
|
import { dirname, join, resolve } from 'node:path';
|
|
33
33
|
import { COMMAND_ARGS, evaluatePrecedent, MUTATING_TOOLS, runAdapterPath, SHELL_TOOLS, transcriptFromJsonlFile, transcriptPathFromPayload, } from '@polydeukes/adapter-claude-code';
|
|
34
|
-
import { appendRecordFailOpen, normalizeProtectedPaths } from '@polydeukes/core';
|
|
35
|
-
import { compileDisciplineRegistrations, dispatchCovenants, transcriptModRegistration, ttlWitness, } from '@polydeukes/covenant';
|
|
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
36
|
import { loadConfig } from './load-config.js';
|
|
37
37
|
/**
|
|
38
38
|
* Compose a judge body path and prove it exists (CONFIG-06b §4.2). A body module that was
|
|
@@ -48,12 +48,103 @@ function provenBodyPath(distDir, fileName) {
|
|
|
48
48
|
}
|
|
49
49
|
return modulePath;
|
|
50
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
|
+
}
|
|
51
142
|
/**
|
|
52
143
|
* Judge one declared tool call before it runs (DIST-01 §3-c). Async because the dispatcher
|
|
53
144
|
* spawns covenant bodies (CORE-01) — a synchronous runner would mean reimplementing the
|
|
54
145
|
* judge, which the single-dispatcher principle forbids.
|
|
55
146
|
*/
|
|
56
|
-
|
|
147
|
+
async function judgeHookCall(spec) {
|
|
57
148
|
// Env-first telemetry precedence (E2E contract), settled BEFORE any failure branch: a
|
|
58
149
|
// config that never loads still has somewhere to write its one blocked row. The config
|
|
59
150
|
// value applies after the load succeeds.
|
|
@@ -67,7 +158,7 @@ export async function runClaudeCodeHook(spec) {
|
|
|
67
158
|
try {
|
|
68
159
|
const envTelemetryPath = process.env.POLYDEUKES_TELEMETRY_PATH;
|
|
69
160
|
telemetryPath =
|
|
70
|
-
spec.telemetryPath ?? envTelemetryPath ?? join(spec.repoRoot,
|
|
161
|
+
spec.telemetryPath ?? envTelemetryPath ?? join(spec.repoRoot, DEFAULT_TELEMETRY_LOG_PATH);
|
|
71
162
|
// Discovery + parse + validation are the loader's job; a throw here (absent, ambiguous,
|
|
72
163
|
// unparseable, or invalid config) falls into the fail-closed catch.
|
|
73
164
|
const { config } = loadConfig(spec.repoRoot);
|
|
@@ -231,3 +322,39 @@ export async function runClaudeCodeHook(spec) {
|
|
|
231
322
|
return { exitCode: 2 };
|
|
232
323
|
}
|
|
233
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
|
+
}
|
package/dist/covenant-check.d.ts
CHANGED
|
@@ -22,15 +22,21 @@
|
|
|
22
22
|
* a state file would be an agent-forgeable surface (PRD §7).
|
|
23
23
|
*
|
|
24
24
|
* fail-closed: a missing/invalid config, an unbuilt judge body, or a collector failure
|
|
25
|
-
* exits 2 with one blocked record
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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).
|
|
28
29
|
*/
|
|
29
30
|
/** `runCovenantCheck` input (ADAPTER-git §4.3 — the contract covenant-check tests pin). */
|
|
30
31
|
export type CovenantCheckSpec = {
|
|
31
32
|
/** Repository root — config discovery and staged collection both anchor here. */
|
|
32
33
|
repoRoot: string;
|
|
33
|
-
/**
|
|
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
|
+
*/
|
|
34
40
|
telemetryPath?: string;
|
|
35
41
|
/** Overrides the resolved covenant dist directory (tests and assembly injection). */
|
|
36
42
|
covenantDist?: string;
|
package/dist/covenant-check.js
CHANGED
|
@@ -22,15 +22,16 @@
|
|
|
22
22
|
* a state file would be an agent-forgeable surface (PRD §7).
|
|
23
23
|
*
|
|
24
24
|
* fail-closed: a missing/invalid config, an unbuilt judge body, or a collector failure
|
|
25
|
-
* exits 2 with one blocked record
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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).
|
|
28
29
|
*/
|
|
29
30
|
import { existsSync } from 'node:fs';
|
|
30
31
|
import { createRequire } from 'node:module';
|
|
31
32
|
import { dirname, join, resolve } from 'node:path';
|
|
32
33
|
import { collectStagedChanges, covenantInputFromStagedChanges, resolveGitAdapterSettings, STAGED_DELETE, STAGED_WRITE, } from '@polydeukes/adapter-git';
|
|
33
|
-
import {
|
|
34
|
+
import { appendRecordFailOpen, DEFAULT_TELEMETRY_LOG_PATH, normalizeProtectedPaths, } from '@polydeukes/core';
|
|
34
35
|
import { compileDisciplineRegistrations, dispatchCovenants, } from '@polydeukes/covenant';
|
|
35
36
|
import { loadConfig } from './load-config.js';
|
|
36
37
|
/**
|
|
@@ -86,21 +87,25 @@ function provenBodyPath(distDir, fileName) {
|
|
|
86
87
|
}
|
|
87
88
|
return modulePath;
|
|
88
89
|
}
|
|
89
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* One blocked record for a run that failed closed before any dispatch could judge.
|
|
92
|
+
*
|
|
93
|
+
* The write goes through `appendRecordFailOpen` rather than the mkdir-free `appendRecord`:
|
|
94
|
+
* a repository that has never been judged has no `.polydeukes/` directory — the shape
|
|
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.
|
|
100
|
+
*/
|
|
90
101
|
function recordFailClosed(telemetryPath) {
|
|
91
102
|
if (telemetryPath === undefined)
|
|
92
103
|
return;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
subject: '-',
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
catch {
|
|
102
|
-
// A telemetry failure must never soften the blocking exit (session-hook precedent).
|
|
103
|
-
}
|
|
104
|
+
appendRecordFailOpen(telemetryPath, {
|
|
105
|
+
event: 'blocked',
|
|
106
|
+
label: 'covenant-check',
|
|
107
|
+
subject: '-',
|
|
108
|
+
});
|
|
104
109
|
}
|
|
105
110
|
/**
|
|
106
111
|
* Judge the staged changes of `repoRoot` exactly as the session surface would
|
|
@@ -109,16 +114,35 @@ function recordFailClosed(telemetryPath) {
|
|
|
109
114
|
* principle forbids.
|
|
110
115
|
*/
|
|
111
116
|
export async function runCovenantCheck(spec) {
|
|
117
|
+
// Telemetry precedence settled BEFORE the failure branch (session-hook precedent): a config
|
|
118
|
+
// that never loads still has somewhere to write its one blocked row, and the config value
|
|
119
|
+
// replaces the provisional default once the load succeeds. The provisional default spells
|
|
120
|
+
// itself with the loader's own constant, so both terms converge on one source.
|
|
121
|
+
//
|
|
122
|
+
// Computed INSIDE the try even though it must run first, because `resolve` throws on a
|
|
123
|
+
// non-string repoRoot and that throw must not escape as a rejection. It leaves
|
|
124
|
+
// `telemetryPath` undefined, which the catch tolerates: there is no root to write a row
|
|
125
|
+
// under anyway.
|
|
126
|
+
//
|
|
127
|
+
// Both terms compose with `resolve`, never `join`: a relative repoRoot would leave the
|
|
128
|
+
// provisional path relative and the post-load one absolute, so a run whose config failed
|
|
129
|
+
// to load would write its row to a different file than the same repository's judgment
|
|
130
|
+
// rows — and a relative path is re-read against the cwd at append time, which need not
|
|
131
|
+
// be the cwd this ran under. The bin always passes `process.cwd()`, so the divergence is
|
|
132
|
+
// reachable only through this exported function, whose `repoRoot` promises no
|
|
133
|
+
// absoluteness (PR #57 review).
|
|
134
|
+
let telemetryPath;
|
|
112
135
|
let config;
|
|
113
136
|
try {
|
|
137
|
+
telemetryPath = spec.telemetryPath ?? resolve(spec.repoRoot, DEFAULT_TELEMETRY_LOG_PATH);
|
|
114
138
|
({ config } = loadConfig(spec.repoRoot));
|
|
139
|
+
telemetryPath = spec.telemetryPath ?? resolve(spec.repoRoot, config.telemetry.logPath);
|
|
115
140
|
}
|
|
116
141
|
catch (error) {
|
|
117
142
|
process.stderr.write(`covenant check failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
118
|
-
recordFailClosed(
|
|
143
|
+
recordFailClosed(telemetryPath);
|
|
119
144
|
return { exitCode: 2 };
|
|
120
145
|
}
|
|
121
|
-
const telemetryPath = spec.telemetryPath ?? resolve(spec.repoRoot, config.telemetry.logPath);
|
|
122
146
|
let changes;
|
|
123
147
|
try {
|
|
124
148
|
changes = collectStagedChanges(spec.repoRoot);
|