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,262 @@
|
|
|
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
|
+
import { existsSync } from 'node:fs';
|
|
31
|
+
import { createRequire } from 'node:module';
|
|
32
|
+
import { dirname, join, resolve } from 'node:path';
|
|
33
|
+
import { collectStagedChanges, covenantInputFromStagedChanges, resolveGitAdapterSettings, STAGED_DELETE, STAGED_WRITE, } from '@polydeukes/adapter-git';
|
|
34
|
+
import { appendRecordFailOpen, DEFAULT_TELEMETRY_LOG_PATH, normalizeProtectedPaths, } from '@polydeukes/core';
|
|
35
|
+
import { compileDisciplineRegistrations, dispatchCovenants, } from '@polydeukes/covenant';
|
|
36
|
+
import { loadConfig } from './load-config.js';
|
|
37
|
+
/**
|
|
38
|
+
* Build the witness predicate for the TTY valve, or undefined when no valve can exist
|
|
39
|
+
* (no witness configured, or no TTY seam — both leave the dispatcher with no way to open
|
|
40
|
+
* one at all). The valve IS the witness: the judge has already broken, and the human at
|
|
41
|
+
* the terminal supplies the pass condition themselves, sudo-style. The prompt fires
|
|
42
|
+
* lazily on the first registration that actually BROKE and names it from the dispatcher's
|
|
43
|
+
* context (COVENANT-17 §4.5) — the label and the MATCHED entry, the same subject the
|
|
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).
|
|
56
|
+
*/
|
|
57
|
+
function ttyWitnessValve(witness, ttyPrompt) {
|
|
58
|
+
if (witness === undefined || ttyPrompt === undefined)
|
|
59
|
+
return undefined;
|
|
60
|
+
const token = witness.token.trim();
|
|
61
|
+
let verdict;
|
|
62
|
+
return (_input, _transcript, context) => {
|
|
63
|
+
if (verdict === undefined) {
|
|
64
|
+
const prompt = `covenant: '${context.label}' broke on the staged change matching '${context.subject}'.\n` +
|
|
65
|
+
'answering opens the valve for the whole commit, not just this change.\n' +
|
|
66
|
+
'type the agreed token in full to open it (enter to refuse): ';
|
|
67
|
+
verdict = false;
|
|
68
|
+
const answer = ttyPrompt(prompt);
|
|
69
|
+
verdict = answer !== null && answer.trim() === token;
|
|
70
|
+
}
|
|
71
|
+
return verdict;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
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
|
+
/**
|
|
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
|
+
*/
|
|
101
|
+
function recordFailClosed(telemetryPath) {
|
|
102
|
+
if (telemetryPath === undefined)
|
|
103
|
+
return;
|
|
104
|
+
appendRecordFailOpen(telemetryPath, {
|
|
105
|
+
event: 'blocked',
|
|
106
|
+
label: 'covenant-check',
|
|
107
|
+
subject: '-',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Judge the staged changes of `repoRoot` exactly as the session surface would
|
|
112
|
+
* (ADAPTER-git §4.3). Async because the dispatcher spawns covenant bodies (CORE-01) —
|
|
113
|
+
* a synchronous runner would mean reimplementing the judge, which the single-dispatcher
|
|
114
|
+
* principle forbids.
|
|
115
|
+
*/
|
|
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;
|
|
135
|
+
let config;
|
|
136
|
+
try {
|
|
137
|
+
telemetryPath = spec.telemetryPath ?? resolve(spec.repoRoot, DEFAULT_TELEMETRY_LOG_PATH);
|
|
138
|
+
({ config } = loadConfig(spec.repoRoot));
|
|
139
|
+
telemetryPath = spec.telemetryPath ?? resolve(spec.repoRoot, config.telemetry.logPath);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
process.stderr.write(`covenant check failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
143
|
+
recordFailClosed(telemetryPath);
|
|
144
|
+
return { exitCode: 2 };
|
|
145
|
+
}
|
|
146
|
+
let changes;
|
|
147
|
+
try {
|
|
148
|
+
changes = collectStagedChanges(spec.repoRoot);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
process.stderr.write(`covenant check failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
152
|
+
recordFailClosed(telemetryPath);
|
|
153
|
+
return { exitCode: 2 };
|
|
154
|
+
}
|
|
155
|
+
if (changes.length === 0) {
|
|
156
|
+
return { exitCode: 0 };
|
|
157
|
+
}
|
|
158
|
+
// Everything from here on is judgment assembly and dispatch: any throw (an unbuilt or
|
|
159
|
+
// unresolvable covenant dist, a registration-build failure) is unjudgeable and must
|
|
160
|
+
// both block AND leave one blocked record — the session hook's one-call-one-record
|
|
161
|
+
// invariant, which an unrecorded propagation to the bin's catch would narrow
|
|
162
|
+
// (review F5).
|
|
163
|
+
try {
|
|
164
|
+
// The adapter namespace validator throws on unknown levels/keys (CONFIG-06 §4.2) —
|
|
165
|
+
// resolved inside this try so a misconfiguration fails closed, never softens.
|
|
166
|
+
const { enforce, protectedPaths: gitAdditivePaths } = resolveGitAdapterSettings(config.adapters?.git);
|
|
167
|
+
// The commit surface judges the UNION of the common list and the git namespace's
|
|
168
|
+
// additive one (CONFIG-08 §4.2) — common first, so first-occurrence dedupe inside
|
|
169
|
+
// the one normalization pass is deterministic. The session hook reads the common
|
|
170
|
+
// list alone; that asymmetry is the contract, not an omission.
|
|
171
|
+
const protectedPaths = normalizeProtectedPaths({
|
|
172
|
+
protectedPaths: [...(config.protectedPaths ?? []), ...gitAdditivePaths],
|
|
173
|
+
});
|
|
174
|
+
// The judge bodies are the covenant package's dist executables — resolved through
|
|
175
|
+
// the real package (never a test alias), so the commit surface spawns the same
|
|
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
|
+
}
|
|
237
|
+
let blocked = false;
|
|
238
|
+
let advisedCount = 0;
|
|
239
|
+
for (const change of changes) {
|
|
240
|
+
const input = covenantInputFromStagedChanges([change]);
|
|
241
|
+
const { exitCode, results } = await dispatchCovenants({
|
|
242
|
+
stdinPayload: JSON.stringify(input),
|
|
243
|
+
registrations,
|
|
244
|
+
telemetryPath,
|
|
245
|
+
dispatcherLabel: 'covenant-check',
|
|
246
|
+
enforce,
|
|
247
|
+
});
|
|
248
|
+
if (exitCode === 2)
|
|
249
|
+
blocked = true;
|
|
250
|
+
advisedCount += results.filter((result) => result.event === 'advised').length;
|
|
251
|
+
}
|
|
252
|
+
if (advisedCount > 0) {
|
|
253
|
+
process.stderr.write(`covenant advisory (enforce: advise): ${advisedCount} verdict(s) recorded, commit allowed\n`);
|
|
254
|
+
}
|
|
255
|
+
return { exitCode: blocked ? 2 : 0 };
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
process.stderr.write(`covenant check failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
259
|
+
recordFailClosed(telemetryPath);
|
|
260
|
+
return { exitCode: 2 };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Configuring Polydeukes
|
|
2
|
+
|
|
3
|
+
**English** · [한국어](./configuration.ko.md)
|
|
4
|
+
|
|
5
|
+
> Alpha. This guide covers the config surface as shipped today (schema v2, loader, and
|
|
6
|
+
> the four built-in discipline predicates). Fields and predicates will grow; what is
|
|
7
|
+
> written here is tested and enforced now.
|
|
8
|
+
|
|
9
|
+
`polydeukes.config.yaml` is the one file where a project declares its disciplines — the
|
|
10
|
+
promises the human and the AI partner both agree to be bound by. It is **data, not code**:
|
|
11
|
+
nothing in it can compute, so nothing in it can lie. The core validates it, the covenant
|
|
12
|
+
package enforces it, and every judgment it causes is measured.
|
|
13
|
+
|
|
14
|
+
This is the guide layer: the file itself, how discovery fails, the IDE wiring, and what
|
|
15
|
+
enforcement looks like. Every key, with its full rules and pitfalls, lives in
|
|
16
|
+
[the configuration reference](./reference/configuration.md).
|
|
17
|
+
|
|
18
|
+
## The file
|
|
19
|
+
|
|
20
|
+
Put exactly one of these at the project root:
|
|
21
|
+
|
|
22
|
+
| Filename | Note |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `polydeukes.config.yaml` | canonical |
|
|
25
|
+
| `polydeukes.config.yml` | accepted variant |
|
|
26
|
+
| `polydeukes.config.json` | accepted variant (read by the same parser — YAML is a JSON superset) |
|
|
27
|
+
|
|
28
|
+
Discovery is deliberately strict, and every failure refuses loudly instead of guessing:
|
|
29
|
+
|
|
30
|
+
- **No config found** → error naming all three candidate filenames. A missing config never
|
|
31
|
+
silently loads defaults — silent defaults would mean silently unprotected.
|
|
32
|
+
- **More than one found** → error naming the collisions. Ambiguity never picks a winner.
|
|
33
|
+
- **Parse error, or a custom YAML tag** → error naming the file. Custom tags are rejected
|
|
34
|
+
even though the parser cannot execute them — config data stays uncomputable by contract.
|
|
35
|
+
- **Schema violation** → error naming the key and the file. Unknown keys are rejected
|
|
36
|
+
wherever the core owns the vocabulary — the top level, and the fixed keys inside a
|
|
37
|
+
discipline entry — so `protectedPath:` for `protectedPaths:`, or `adaptors:` for
|
|
38
|
+
`adapters:`, is caught here. Two maps stay open, because their keys are your values
|
|
39
|
+
rather than the core's: language names under `languages`, and adapter names under
|
|
40
|
+
`adapters`. A misspelt adapter name is accepted and its block simply goes unread, which
|
|
41
|
+
leaves that adapter on its defaults — check the name against the adapter's own reference.
|
|
42
|
+
Inside a namespace the vocabulary belongs to that adapter: the core passes contents
|
|
43
|
+
through verbatim, and the adapter's own validator rejects what it does not recognise,
|
|
44
|
+
naming the full field path (see
|
|
45
|
+
[the `adapters` reference](./reference/configuration.md#adapters)).
|
|
46
|
+
|
|
47
|
+
## IDE support
|
|
48
|
+
|
|
49
|
+
The JSON Schema gives autocompletion and validation in editors. It ships inside the
|
|
50
|
+
`polydeukes` package, so the line names a path into your own `node_modules`:
|
|
51
|
+
|
|
52
|
+
```yaml
|
|
53
|
+
# yaml-language-server: $schema=node_modules/polydeukes/dist/schema/polydeukes.schema.json
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
For a JSON config, use the standard top-level key instead. The loader accepts it and drops
|
|
57
|
+
it from the resolved config:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{ "$schema": "node_modules/polydeukes/dist/schema/polydeukes.schema.json" }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**The path is resolved against the directory your config sits in**, not against a project
|
|
64
|
+
root the editor infers. The spelling above is right when the two are the same place. When
|
|
65
|
+
they are not — a config in a monorepo sub-package whose dependencies installed at the
|
|
66
|
+
workspace root — count the levels up yourself:
|
|
67
|
+
|
|
68
|
+
```yaml
|
|
69
|
+
# yaml-language-server: $schema=../../node_modules/polydeukes/dist/schema/polydeukes.schema.json
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`pdks init claude-code` writes the line only when the schema is where the plain spelling
|
|
73
|
+
names it. If the generated config has no such line, that is the case above, and the prefix
|
|
74
|
+
is yours to add — an unresolvable path costs you validation without reporting anything.
|
|
75
|
+
|
|
76
|
+
If you installed `@polydeukes/core` directly rather than the umbrella, name its own copy:
|
|
77
|
+
|
|
78
|
+
```yaml
|
|
79
|
+
# yaml-language-server: $schema=node_modules/@polydeukes/core/schema/polydeukes.schema.json
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Every value here is a **file path**, not a module specifier: `$schema` is a static string an
|
|
83
|
+
editor reads, so no module resolver runs on it. Code that reads the schema at runtime uses
|
|
84
|
+
the package subpath `polydeukes/schema.json` instead.
|
|
85
|
+
|
|
86
|
+
## What enforcement looks like
|
|
87
|
+
|
|
88
|
+
A violating tool call or shell command is **blocked (exit 2)** before it runs, with the
|
|
89
|
+
discipline's `id` in the telemetry record. The sanctioned valve is the witness — a human
|
|
90
|
+
supplying the pass condition on a judgment that actually blocked, recorded as
|
|
91
|
+
`witnessed` — never silent. On the commit surface under
|
|
92
|
+
`adapters.git.enforce: advise`, a verdict is recorded as `advised` and the commit
|
|
93
|
+
proceeds — a backstop that measures instead of blocking. A missing, ambiguous, or
|
|
94
|
+
invalid config blocks every call until it is fixed: the system fails closed, because a
|
|
95
|
+
dead gate that waves things through is the cheapest bypass of all.
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# Installing Polydeukes
|
|
2
|
+
|
|
3
|
+
**English** · [한국어](./installation.ko.md)
|
|
4
|
+
|
|
5
|
+
> Alpha. This guide covers the install paths that ship today, and everything here is the
|
|
6
|
+
> measured behaviour of the published packages.
|
|
7
|
+
|
|
8
|
+
This is the getting-started layer: from an empty project to a first judged call.
|
|
9
|
+
|
|
10
|
+
One devDependency, one command per surface. The umbrella package `polydeukes` is the only
|
|
11
|
+
thing you install — it carries the core, the judge, and the adapters as its own
|
|
12
|
+
dependencies, and `pdks` is its CLI (an alias of `polydeukes`).
|
|
13
|
+
|
|
14
|
+
**Two surfaces ship, for two different situations — pick the one that matches how the
|
|
15
|
+
project is developed.** A project built alongside an AI partner in Claude Code wires the
|
|
16
|
+
**session surface**: a PreToolUse hook that judges every editing tool call and shell
|
|
17
|
+
command as it is declared. A project you develop yourself wires the **commit surface**: a
|
|
18
|
+
pre-commit hook that judges the staged diff, so the discipline you declared for yourself
|
|
19
|
+
is applied at the moment work becomes history. They enforce the same config vocabulary,
|
|
20
|
+
but they answer different situations — there is no general reason to wire both in one
|
|
21
|
+
project.
|
|
22
|
+
|
|
23
|
+
## Prerequisites
|
|
24
|
+
|
|
25
|
+
- **Node.js ≥ 24** — the engines floor of every published package.
|
|
26
|
+
- **A package manager** — pnpm and npm both work; examples below use pnpm.
|
|
27
|
+
- **Claude Code** — only for the session surface. The commit surface needs no AI tool at
|
|
28
|
+
all: just git and a way to run a pre-commit hook.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
pnpm add -D polydeukes
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
(or `npm install --save-dev polydeukes`.)
|
|
37
|
+
|
|
38
|
+
This must be a real project dependency, not a one-off `npx` run — both surfaces load the
|
|
39
|
+
judge from your project's own installed package.
|
|
40
|
+
|
|
41
|
+
## The session surface — developing with an AI partner
|
|
42
|
+
|
|
43
|
+
From the project root:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
pnpm exec pdks init claude-code
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The command installs into the directory it is invoked from, and it proves the `polydeukes`
|
|
50
|
+
package resolves there **before writing anything** — if it does not (say, the install step
|
|
51
|
+
was skipped), it prints the install command and exits 2 with zero files written, never a
|
|
52
|
+
half-wired tree.
|
|
53
|
+
|
|
54
|
+
Five artifacts, none ever overwritten. What exists is reported and kept — the hook, the
|
|
55
|
+
config, and the discipline file are left alone, the settings file is merged, and
|
|
56
|
+
`.gitignore` is only ever appended to — so re-running is always safe:
|
|
57
|
+
|
|
58
|
+
| Artifact | What it is |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `.claude/hooks/covenant-pretooluse.mjs` | The hook — a thin delegator that loads the judge from the installed package. Upgrading the package upgrades the judge; this file never changes. |
|
|
61
|
+
| `.claude/settings.json` | The PreToolUse registration for editing tools and shell calls. **Merged, never replaced** — your other hooks and permissions stay. |
|
|
62
|
+
| `polydeukes.config.yaml` | The starter protection policy: a placeholder `languages` block, a minimum `protectedPaths` list, and the witness block. The comments in the file explain why each entry is there. |
|
|
63
|
+
| `.claude/rules/polydeukes.md` | A scoped discipline file telling your AI partner that `pdks docs` exists and which topic answers what. It carries `paths` frontmatter, so it loads when a Polydeukes path is in play rather than sitting in every session's context. |
|
|
64
|
+
| `.gitignore` | An appended ignore rule for `.polydeukes/`, with its comment line — telemetry is local observation data and never belongs in history. |
|
|
65
|
+
|
|
66
|
+
## First edit — `languages`
|
|
67
|
+
|
|
68
|
+
The generated config ships a placeholder language profile, because the installer cannot
|
|
69
|
+
know your stack:
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
languages:
|
|
73
|
+
placeholder:
|
|
74
|
+
productionGlob: 'src/**'
|
|
75
|
+
testCmd: 'echo "set a verification command for {scope}"'
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Rename the key to your language, point `productionGlob` at your production sources, and put
|
|
79
|
+
your real verification command in `testCmd`. (On the commit-surface path you write this
|
|
80
|
+
block yourself as part of the config below.) The placeholder is valid as generated and no
|
|
81
|
+
judgment path reads these values yet, so it cannot produce a wrong verdict while it waits —
|
|
82
|
+
but `languages` is the schema's one required block, so *removing* it (or emptying it) makes
|
|
83
|
+
the config invalid, and an invalid config blocks every call. Edit it, don't delete it.
|
|
84
|
+
|
|
85
|
+
## The commit surface — developing by yourself
|
|
86
|
+
|
|
87
|
+
This path is for applying your own discipline to your own commits — no AI tool involved.
|
|
88
|
+
It has no installer today; the wiring is two small manual steps.
|
|
89
|
+
|
|
90
|
+
**First, the config.** Create `polydeukes.config.yaml` at the project root (there is no
|
|
91
|
+
generator on this path — the file is yours from the first line):
|
|
92
|
+
|
|
93
|
+
```yaml
|
|
94
|
+
languages:
|
|
95
|
+
typescript:
|
|
96
|
+
productionGlob: 'src/**'
|
|
97
|
+
testCmd: 'pnpm test'
|
|
98
|
+
|
|
99
|
+
# Judged at commit time: a staged change to these paths stops the commit
|
|
100
|
+
# until you answer the witness prompt in person.
|
|
101
|
+
protectedPaths:
|
|
102
|
+
- 'db/migrations'
|
|
103
|
+
|
|
104
|
+
witness:
|
|
105
|
+
token: 'pdks witness'
|
|
106
|
+
ttlMinutes: 10
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Add `.polydeukes/` to your `.gitignore` too — telemetry is local observation data.
|
|
110
|
+
|
|
111
|
+
**Then, the hook.** One command judges what is currently staged and exits 2 on a broken
|
|
112
|
+
covenant:
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
pnpm exec pdks covenant check
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Register it as a pre-commit hook. With **lefthook**:
|
|
119
|
+
|
|
120
|
+
```yaml
|
|
121
|
+
# lefthook.yml
|
|
122
|
+
pre-commit:
|
|
123
|
+
commands:
|
|
124
|
+
covenant:
|
|
125
|
+
priority: 1
|
|
126
|
+
interactive: true # keep the witness prompt visible — see below
|
|
127
|
+
run: ./node_modules/.bin/pdks covenant check
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
With **husky**:
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
# .husky/pre-commit
|
|
134
|
+
./node_modules/.bin/pdks covenant check
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
With plain **`.git/hooks`** (make it executable):
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
#!/bin/sh
|
|
141
|
+
# .git/hooks/pre-commit
|
|
142
|
+
./node_modules/.bin/pdks covenant check
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Three things to know about this surface:
|
|
146
|
+
|
|
147
|
+
- **The valve is a TTY prompt.** At the default `block` level, a commit that stages a
|
|
148
|
+
protected change stops at a prompt only a human at a terminal can answer. Configure your
|
|
149
|
+
hook runner so it does not swallow that prompt (lefthook needs `interactive: true`).
|
|
150
|
+
- **Two discipline families judge here.** A staged diff carries file changes and nothing
|
|
151
|
+
else, so protection lists and the delta and path families (`forbid`, `immutable`) judge
|
|
152
|
+
in full. A command-family entry (`forbidCommand`) has no command line to read in a
|
|
153
|
+
staged diff and is not assembled on this surface, and a context-family entry
|
|
154
|
+
(`requirePrecedent`) is recorded as `skipped` — declare those two where an AI partner's
|
|
155
|
+
session exists to be judged.
|
|
156
|
+
- **The commit surface has its own additive scope.** Paths that are fine to edit freely
|
|
157
|
+
but whose promotion into history deserves a judged checkpoint go under the adapter
|
|
158
|
+
namespace, judged on top of the shared list:
|
|
159
|
+
|
|
160
|
+
```yaml
|
|
161
|
+
adapters:
|
|
162
|
+
git:
|
|
163
|
+
protectedPaths:
|
|
164
|
+
- 'src/policy'
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## The witness valve
|
|
168
|
+
|
|
169
|
+
Both surfaces carry the same valve, spelled for their situation. It sits **after** the
|
|
170
|
+
verdict — only a judgment that actually blocked can be witnessed open — and every allowance
|
|
171
|
+
is recorded as `witnessed`, never silent.
|
|
172
|
+
|
|
173
|
+
```yaml
|
|
174
|
+
witness:
|
|
175
|
+
token: 'pdks witness'
|
|
176
|
+
ttlMinutes: 10
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
- **Session surface:** a human types the token so it stands alone on the first line of a
|
|
180
|
+
conversation message; the window holds for `ttlMinutes`, then blocking resumes. An agent
|
|
181
|
+
cannot open the valve for itself — only human-authored messages count.
|
|
182
|
+
- **Commit surface:** the blocked commit shows a TTY prompt, and typing the full token
|
|
183
|
+
there opens that one commit.
|
|
184
|
+
|
|
185
|
+
Change the token and window as you like — the token is not a secret; the defence is
|
|
186
|
+
provenance, not confidentiality. **Keep the block**: on the session surface the generated
|
|
187
|
+
protection list covers `.claude/hooks`, so without a valve the first blocked call would
|
|
188
|
+
freeze the project until a human edits the config from their own terminal.
|
|
189
|
+
|
|
190
|
+
## Prove the gate is live
|
|
191
|
+
|
|
192
|
+
Prove it once on the surface you wired, then read the telemetry.
|
|
193
|
+
|
|
194
|
+
- **Session surface:** ask your agent to append a line to
|
|
195
|
+
`.claude/hooks/covenant-pretooluse.mjs` (a protected path). The call must come back
|
|
196
|
+
blocked.
|
|
197
|
+
- **Commit surface:** stage an edit to a path on your protection list and run
|
|
198
|
+
`git commit`. It must stop at the witness prompt (answer it, or abort with Ctrl-C).
|
|
199
|
+
|
|
200
|
+
```sh
|
|
201
|
+
cat .polydeukes/roi.log
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Every judgment appends exactly one record — `passed`, `blocked`, `witnessed`, `advised`, or
|
|
205
|
+
`skipped` — so the block you just caused is the last line. A gate you have watched block
|
|
206
|
+
once is a gate you know is wired.
|
|
207
|
+
|
|
208
|
+
From here: [the configuration guide](./configuration.md) for the file and its wiring,
|
|
209
|
+
[the configuration reference](./reference/configuration.md) for every field and for
|
|
210
|
+
writing your own disciplines, and [troubleshooting](./troubleshooting.md) when something
|
|
211
|
+
blocks and you don't know why.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# `@polydeukes/adapter-claude-code`
|
|
2
|
+
|
|
3
|
+
**English** · [한국어](./adapter-claude-code.ko.md)
|
|
4
|
+
|
|
5
|
+
> **The session surface's translator** — PreToolUse payloads become the covenant input IR,
|
|
6
|
+
> with the file-change evidence and the transcript channel the judge reads.
|
|
7
|
+
>
|
|
8
|
+
> Alpha. A transitive dependency of the umbrella: you do not install it and you do not
|
|
9
|
+
> import it. The session surface reaches it through
|
|
10
|
+
> [`polydeukes/claude-code`](./polydeukes.md#subpaths).
|
|
11
|
+
|
|
12
|
+
## What this package owns
|
|
13
|
+
|
|
14
|
+
The boundary where Claude Code's vocabulary is translated away. Agent and tool literals
|
|
15
|
+
live *here* by design, so that they never reach the core — which is what makes the core's
|
|
16
|
+
agent-neutrality a claim a test can check rather than a slogan.
|
|
17
|
+
|
|
18
|
+
| Unit | What it does |
|
|
19
|
+
|---|---|
|
|
20
|
+
| Payload up-translation | A raw PreToolUse payload becomes a `CovenantInput` |
|
|
21
|
+
| Virtual post-state | Computes what a file *would* contain after an edit applies, without touching disk |
|
|
22
|
+
| File-change evidence | Pairs the disk pre-state with the virtual post-state into union evidence |
|
|
23
|
+
| Transcript provider | Turns a session JSONL file into a `CanonicalTranscript` |
|
|
24
|
+
| Precedent evaluator | This adapter's own evidence vocabulary for the context family |
|
|
25
|
+
| Telemetry wiring | Drives the full funnel so exactly one row lands per call |
|
|
26
|
+
|
|
27
|
+
This package never imports the covenant package. The dispatch seam is *injected* by the
|
|
28
|
+
umbrella, which keeps dependencies one-way, through the core alone.
|
|
29
|
+
|
|
30
|
+
## Payload translation and the three axes
|
|
31
|
+
|
|
32
|
+
**Three axes reach the judge**, and they differ in what evidence they can carry.
|
|
33
|
+
|
|
34
|
+
| Axis | Carries | Consequence |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| Tool | A proven `fileChange` — the mutation target computed before the tool runs | Only the proven target is judged. A protected path inside an edit's *content* is a mention and passes |
|
|
37
|
+
| Shell | A command line whose target is often not computable before execution | Computable writes are judged like an edit; the rest is recorded rather than guessed |
|
|
38
|
+
| Transcript | The session's own record | Judged by whole-path equality, never as a protected ancestor |
|
|
39
|
+
|
|
40
|
+
Translation is fail-closed at every step. A `Task` call carrying a subagent type maps to a
|
|
41
|
+
spawn; a payload that cannot be classified is a translation *failure* that logs one
|
|
42
|
+
`blocked` record and exits `2`, rather than degrading into a guess.
|
|
43
|
+
|
|
44
|
+
**Evidence is computed, never read back.** The virtual post-state applies `Edit`, `Write`,
|
|
45
|
+
and `MultiEdit` in memory — sequential multi-edit application included — so a content-aware
|
|
46
|
+
discipline judges the *proposed* result rather than the file as it currently is. An
|
|
47
|
+
unresolvable post-state yields no evidence at all, because the real tool would reject the
|
|
48
|
+
same edit, and evidence is never fabricated for a non-mutating call.
|
|
49
|
+
|
|
50
|
+
**The transcript admits only positively-identified human messages.** That is what makes the
|
|
51
|
+
witness valve human-only: an AI cannot synthesize its own witness. A read failure answers
|
|
52
|
+
`undefined` rather than an empty transcript — an empty session has said nothing yet and is
|
|
53
|
+
judged, an unreadable one is no evidence channel at all and is skipped. Either way the
|
|
54
|
+
valve turns off, never open.
|
|
55
|
+
|
|
56
|
+
**The precedent evaluator judges two keys.** `subagent` is exact spawn-kind equality, since
|
|
57
|
+
a kind is a value rather than a pattern; `tool` matches observed tool names as a regular
|
|
58
|
+
expression. Any key outside this vocabulary returns `undefined` — the handshake that tells
|
|
59
|
+
the compiler the evidence is unjudgeable, so the entry skips instead of judging on a guess.
|
|
60
|
+
|
|
61
|
+
## Where the consumer touches it
|
|
62
|
+
|
|
63
|
+
- **The generated hook**, which loads this adapter through the umbrella's `claude-code`
|
|
64
|
+
subpath. Upgrading the package upgrades what runs; the hook file itself never changes.
|
|
65
|
+
- **`requirePrecedent` entries** using the `subagent` or `tool` evidence keys.
|
|
66
|
+
|
|
67
|
+
No import, and no configuration namespace of its own.
|
|
68
|
+
|
|
69
|
+
## Declared limits
|
|
70
|
+
|
|
71
|
+
- **A child process's writes are outside observation.** This surface judges *declared tool
|
|
72
|
+
calls*. A command that spawns a process which then writes files — a test runner, a build
|
|
73
|
+
— is judged on the command, not on what the child did. The commit surface is the second
|
|
74
|
+
observation that covers the same ground for tracked files.
|
|
75
|
+
- **Evidence exists only where a post-state can be computed.** All four mutating tools
|
|
76
|
+
contribute one, notebooks included — a `NotebookEdit` yields cell-level `modify` evidence.
|
|
77
|
+
What yields nothing is a payload this adapter cannot resolve: an unreadable or unparseable
|
|
78
|
+
notebook, a cell it cannot name, an edit mode it does not know.
|
|
79
|
+
- **An evidence-free call falls back to the conservative judgment** — the call's arguments
|
|
80
|
+
are compared for a mention rather than a proven target.
|
|
81
|
+
- **Out-of-repository ancestors stay out of scope.** A path above the project root is not
|
|
82
|
+
observed here; the agent's own deny policy owns that ground.
|