polydeukes 0.0.1 → 0.3.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 +47 -0
- package/dist/claude-code-hook.js +233 -0
- package/dist/covenant-check.d.ts +52 -0
- package/dist/covenant-check.js +238 -0
- package/dist/docs/configuration.md +361 -0
- package/dist/docs/installation.md +208 -0
- package/dist/docs/reference/adapter-claude-code.md +82 -0
- package/dist/docs/reference/adapter-git.md +87 -0
- package/dist/docs/reference/core.md +111 -0
- package/dist/docs/reference/covenant.md +100 -0
- package/dist/docs/reference/polydeukes.md +210 -0
- package/dist/docs/troubleshooting.md +152 -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 +42 -0
- package/dist/load-config.js +83 -0
- package/dist/scaffold-project.d.ts +37 -0
- package/dist/scaffold-project.js +123 -0
- package/package.json +29 -10
|
@@ -0,0 +1,52 @@
|
|
|
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 when a telemetry path is known. An empty staging area
|
|
26
|
+
* is an explicit pass (nothing to judge — the dispatcher precedent of zero matches, zero
|
|
27
|
+
* records).
|
|
28
|
+
*/
|
|
29
|
+
/** `runCovenantCheck` input (ADAPTER-git §4.3 — the contract covenant-check tests pin). */
|
|
30
|
+
export type CovenantCheckSpec = {
|
|
31
|
+
/** Repository root — config discovery and staged collection both anchor here. */
|
|
32
|
+
repoRoot: string;
|
|
33
|
+
/** Overrides the config's telemetry log path (tests and assembly injection). */
|
|
34
|
+
telemetryPath?: string;
|
|
35
|
+
/** Overrides the resolved covenant dist directory (tests and assembly injection). */
|
|
36
|
+
covenantDist?: string;
|
|
37
|
+
/**
|
|
38
|
+
* TTY valve seam: writes the given prompt and returns the line a human typed, or null
|
|
39
|
+
* for no input. ABSENT means a non-TTY environment — the valve never opens (AC-3
|
|
40
|
+
* human-only arming).
|
|
41
|
+
*/
|
|
42
|
+
ttyPrompt?: (prompt: string) => string | null;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Judge the staged changes of `repoRoot` exactly as the session surface would
|
|
46
|
+
* (ADAPTER-git §4.3). Async because the dispatcher spawns covenant bodies (CORE-01) —
|
|
47
|
+
* a synchronous runner would mean reimplementing the judge, which the single-dispatcher
|
|
48
|
+
* principle forbids.
|
|
49
|
+
*/
|
|
50
|
+
export declare function runCovenantCheck(spec: CovenantCheckSpec): Promise<{
|
|
51
|
+
exitCode: 0 | 2;
|
|
52
|
+
}>;
|
|
@@ -0,0 +1,238 @@
|
|
|
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 when a telemetry path is known. An empty staging area
|
|
26
|
+
* is an explicit pass (nothing to judge — the dispatcher precedent of zero matches, zero
|
|
27
|
+
* records).
|
|
28
|
+
*/
|
|
29
|
+
import { existsSync } from 'node:fs';
|
|
30
|
+
import { createRequire } from 'node:module';
|
|
31
|
+
import { dirname, join, resolve } from 'node:path';
|
|
32
|
+
import { collectStagedChanges, covenantInputFromStagedChanges, resolveGitAdapterSettings, STAGED_DELETE, STAGED_WRITE, } from '@polydeukes/adapter-git';
|
|
33
|
+
import { appendRecord, normalizeProtectedPaths } from '@polydeukes/core';
|
|
34
|
+
import { compileDisciplineRegistrations, dispatchCovenants, } from '@polydeukes/covenant';
|
|
35
|
+
import { loadConfig } from './load-config.js';
|
|
36
|
+
/**
|
|
37
|
+
* Build the witness predicate for the TTY valve, or undefined when no valve can exist
|
|
38
|
+
* (no witness configured, or no TTY seam — both leave the dispatcher with no way to open
|
|
39
|
+
* one at all). The valve IS the witness: the judge has already broken, and the human at
|
|
40
|
+
* the terminal supplies the pass condition themselves, sudo-style. The prompt fires
|
|
41
|
+
* lazily on the first registration that actually BROKE and names it from the dispatcher's
|
|
42
|
+
* context (COVENANT-17 §4.5) — the label and the MATCHED entry, the same subject the
|
|
43
|
+
* telemetry row carries, so screen and log never disagree. The human reads what broke,
|
|
44
|
+
* on what, and how far one answer reaches. The verdict is cached: one commit, at most
|
|
45
|
+
* one prompt, full-token equality only — and the token itself is never printed, or
|
|
46
|
+
* typing it from memory would become copying it off the screen.
|
|
47
|
+
*
|
|
48
|
+
* Both comparison sides are trimmed, mirroring the session valve: `ttlWitness` trims the
|
|
49
|
+
* config token at assembly precisely because config validation accepts a padded value,
|
|
50
|
+
* and it compares the utterance's first line trimmed — without the same normalisation
|
|
51
|
+
* here, one padded token would open the session surface and permanently shut this one
|
|
52
|
+
* (PR #41 review). The cache latches CLOSED before the seam is consulted: a throwing
|
|
53
|
+
* seam must not retry on the next broken registration, or the prompt's own commit-wide
|
|
54
|
+
* promise becomes a lie (AC §5.3 one commit, at most one prompt).
|
|
55
|
+
*/
|
|
56
|
+
function ttyWitnessValve(witness, ttyPrompt) {
|
|
57
|
+
if (witness === undefined || ttyPrompt === undefined)
|
|
58
|
+
return undefined;
|
|
59
|
+
const token = witness.token.trim();
|
|
60
|
+
let verdict;
|
|
61
|
+
return (_input, _transcript, context) => {
|
|
62
|
+
if (verdict === undefined) {
|
|
63
|
+
const prompt = `covenant: '${context.label}' broke on the staged change matching '${context.subject}'.\n` +
|
|
64
|
+
'answering opens the valve for the whole commit, not just this change.\n' +
|
|
65
|
+
'type the agreed token in full to open it (enter to refuse): ';
|
|
66
|
+
verdict = false;
|
|
67
|
+
const answer = ttyPrompt(prompt);
|
|
68
|
+
verdict = answer !== null && answer.trim() === token;
|
|
69
|
+
}
|
|
70
|
+
return verdict;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Compose a judge body's module path and prove the file is there (CONFIG-06b §4.2).
|
|
75
|
+
* Spawning an absent module succeeds and its child exits 1 — the code a break verdict
|
|
76
|
+
* returns — so a judge that ran no line would arrive as a violation and, under `advise`,
|
|
77
|
+
* be waved through. Nothing downstream can separate the two (`translateExitCode` sees
|
|
78
|
+
* that number alone), so the proof happens here, before the spawn. Producing the path
|
|
79
|
+
* and proving it are one step on purpose: a path that skipped the proof cannot be
|
|
80
|
+
* constructed, and only the bodies this surface actually composes are proven.
|
|
81
|
+
*/
|
|
82
|
+
function provenBodyPath(distDir, fileName) {
|
|
83
|
+
const modulePath = join(distDir, fileName);
|
|
84
|
+
if (!existsSync(modulePath)) {
|
|
85
|
+
throw new Error(`judge body ${modulePath} is missing — run 'pnpm build' to rebuild it`);
|
|
86
|
+
}
|
|
87
|
+
return modulePath;
|
|
88
|
+
}
|
|
89
|
+
/** One blocked record for a run that failed closed before any dispatch could judge. */
|
|
90
|
+
function recordFailClosed(telemetryPath) {
|
|
91
|
+
if (telemetryPath === undefined)
|
|
92
|
+
return;
|
|
93
|
+
try {
|
|
94
|
+
appendRecord(telemetryPath, {
|
|
95
|
+
timestamp: new Date().toISOString(),
|
|
96
|
+
event: 'blocked',
|
|
97
|
+
label: 'covenant-check',
|
|
98
|
+
subject: '-',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// A telemetry failure must never soften the blocking exit (session-hook precedent).
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Judge the staged changes of `repoRoot` exactly as the session surface would
|
|
107
|
+
* (ADAPTER-git §4.3). Async because the dispatcher spawns covenant bodies (CORE-01) —
|
|
108
|
+
* a synchronous runner would mean reimplementing the judge, which the single-dispatcher
|
|
109
|
+
* principle forbids.
|
|
110
|
+
*/
|
|
111
|
+
export async function runCovenantCheck(spec) {
|
|
112
|
+
let config;
|
|
113
|
+
try {
|
|
114
|
+
({ config } = loadConfig(spec.repoRoot));
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
process.stderr.write(`covenant check failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
118
|
+
recordFailClosed(spec.telemetryPath);
|
|
119
|
+
return { exitCode: 2 };
|
|
120
|
+
}
|
|
121
|
+
const telemetryPath = spec.telemetryPath ?? resolve(spec.repoRoot, config.telemetry.logPath);
|
|
122
|
+
let changes;
|
|
123
|
+
try {
|
|
124
|
+
changes = collectStagedChanges(spec.repoRoot);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
process.stderr.write(`covenant check failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
128
|
+
recordFailClosed(telemetryPath);
|
|
129
|
+
return { exitCode: 2 };
|
|
130
|
+
}
|
|
131
|
+
if (changes.length === 0) {
|
|
132
|
+
return { exitCode: 0 };
|
|
133
|
+
}
|
|
134
|
+
// Everything from here on is judgment assembly and dispatch: any throw (an unbuilt or
|
|
135
|
+
// unresolvable covenant dist, a registration-build failure) is unjudgeable and must
|
|
136
|
+
// both block AND leave one blocked record — the session hook's one-call-one-record
|
|
137
|
+
// invariant, which an unrecorded propagation to the bin's catch would narrow
|
|
138
|
+
// (review F5).
|
|
139
|
+
try {
|
|
140
|
+
// The adapter namespace validator throws on unknown levels/keys (CONFIG-06 §4.2) —
|
|
141
|
+
// resolved inside this try so a misconfiguration fails closed, never softens.
|
|
142
|
+
const { enforce, protectedPaths: gitAdditivePaths } = resolveGitAdapterSettings(config.adapters?.git);
|
|
143
|
+
// The commit surface judges the UNION of the common list and the git namespace's
|
|
144
|
+
// additive one (CONFIG-08 §4.2) — common first, so first-occurrence dedupe inside
|
|
145
|
+
// the one normalization pass is deterministic. The session hook reads the common
|
|
146
|
+
// list alone; that asymmetry is the contract, not an omission.
|
|
147
|
+
const protectedPaths = normalizeProtectedPaths({
|
|
148
|
+
protectedPaths: [...(config.protectedPaths ?? []), ...gitAdditivePaths],
|
|
149
|
+
});
|
|
150
|
+
// The judge bodies are the covenant package's dist executables — resolved through
|
|
151
|
+
// the real package (never a test alias), so the commit surface spawns the same
|
|
152
|
+
// judges the session hook does. An injected directory overrides that resolution:
|
|
153
|
+
// `createRequire` is real Node resolution and always lands on the real build, which
|
|
154
|
+
// no fixture can take a body away from.
|
|
155
|
+
const covenantDist = spec.covenantDist ?? dirname(createRequire(import.meta.url).resolve('@polydeukes/covenant'));
|
|
156
|
+
// Under advise the TTY valve is structurally absent (CONFIG-06 §4.6): a verdict
|
|
157
|
+
// already passes, so there is nothing to witness and the prompt must never fire.
|
|
158
|
+
const witness = enforce === 'advise' ? undefined : ttyWitnessValve(config.witness, spec.ttyPrompt);
|
|
159
|
+
const disciplines = config.disciplines ?? [];
|
|
160
|
+
const registrations = [
|
|
161
|
+
{
|
|
162
|
+
label: 'self-mod',
|
|
163
|
+
protectedPaths,
|
|
164
|
+
body: {
|
|
165
|
+
command: process.execPath,
|
|
166
|
+
args: [
|
|
167
|
+
provenBodyPath(covenantDist, 'self-mod-body.js'),
|
|
168
|
+
...protectedPaths.flatMap((path) => ['--protected-path', path]),
|
|
169
|
+
...[STAGED_WRITE, STAGED_DELETE].flatMap((tool) => ['--mutating-tool', tool]),
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
witness,
|
|
173
|
+
},
|
|
174
|
+
// Command-family entries are excluded: the commit surface has no shell axis (a
|
|
175
|
+
// staged diff carries no commands), so registering them would be spawn waste by
|
|
176
|
+
// design (PRD §2) — a vacuous exclusion, hence recorded nowhere. Path and delta
|
|
177
|
+
// families judge the staged fileChanges as-is.
|
|
178
|
+
//
|
|
179
|
+
// Context-family entries are NOT filtered out any more. No transcript is injected
|
|
180
|
+
// here, so the compiler gives them skip registrations, and a skip records one
|
|
181
|
+
// `skipped` exactly when its trigger matches a staged change (COVENANT-13 §4.5).
|
|
182
|
+
// The commit surface stopped being a special case: an absent evidence channel gets
|
|
183
|
+
// the same disposition on both surfaces, and the scope gate comes free with the
|
|
184
|
+
// routing every registration already carries.
|
|
185
|
+
//
|
|
186
|
+
// The body path is passed as a thunk, so the proof fires only where the compiler
|
|
187
|
+
// actually composes a body (CONFIG-06b §4.2 corollary). Entry count cannot stand in
|
|
188
|
+
// for that: an entry may compile to a body-less skip — every `requirePrecedent` one
|
|
189
|
+
// does here, since this surface injects neither transcript nor evaluator — and the
|
|
190
|
+
// compiler appends the body-less `shell-unjudgeable` backstop even for zero entries,
|
|
191
|
+
// so gating the call itself would drop that record.
|
|
192
|
+
...compileDisciplineRegistrations({
|
|
193
|
+
disciplines: disciplines.filter((entry) => entry.forbidCommand === undefined),
|
|
194
|
+
rootDir: spec.repoRoot,
|
|
195
|
+
bodyCommand: process.execPath,
|
|
196
|
+
bodyModulePath: () => provenBodyPath(covenantDist, 'discipline-body.js'),
|
|
197
|
+
shellTools: [],
|
|
198
|
+
commandArgs: [],
|
|
199
|
+
witness,
|
|
200
|
+
}),
|
|
201
|
+
];
|
|
202
|
+
// The commit surface resolves the compiler through the installed package, so a
|
|
203
|
+
// workspace whose dist predates the lazy body-path convention hands back the thunk
|
|
204
|
+
// itself where a string belongs. `spawn` stringifies rather than rejects it, which
|
|
205
|
+
// would spawn the judge on the thunk's own source text and record the exit 1 as a
|
|
206
|
+
// verdict under a discipline's label — the confusion this ticket removes, arriving
|
|
207
|
+
// through the build-skew door. Assert the shape and let the fail-closed catch answer.
|
|
208
|
+
for (const registration of registrations) {
|
|
209
|
+
if (registration.body !== undefined && typeof registration.body.args?.[0] !== 'string') {
|
|
210
|
+
throw new Error(`covenant dist predates the lazy body-path convention (registration '${registration.label}') — run 'pnpm build'`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
let blocked = false;
|
|
214
|
+
let advisedCount = 0;
|
|
215
|
+
for (const change of changes) {
|
|
216
|
+
const input = covenantInputFromStagedChanges([change]);
|
|
217
|
+
const { exitCode, results } = await dispatchCovenants({
|
|
218
|
+
stdinPayload: JSON.stringify(input),
|
|
219
|
+
registrations,
|
|
220
|
+
telemetryPath,
|
|
221
|
+
dispatcherLabel: 'covenant-check',
|
|
222
|
+
enforce,
|
|
223
|
+
});
|
|
224
|
+
if (exitCode === 2)
|
|
225
|
+
blocked = true;
|
|
226
|
+
advisedCount += results.filter((result) => result.event === 'advised').length;
|
|
227
|
+
}
|
|
228
|
+
if (advisedCount > 0) {
|
|
229
|
+
process.stderr.write(`covenant advisory (enforce: advise): ${advisedCount} verdict(s) recorded, commit allowed\n`);
|
|
230
|
+
}
|
|
231
|
+
return { exitCode: blocked ? 2 : 0 };
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
process.stderr.write(`covenant check failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
235
|
+
recordFailClosed(telemetryPath);
|
|
236
|
+
return { exitCode: 2 };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# Configuring Polydeukes
|
|
2
|
+
|
|
3
|
+
**English** · [한국어](./configuration.ko.md)
|
|
4
|
+
|
|
5
|
+
> Alpha. This reference describes the config surface as shipped today (schema v2,
|
|
6
|
+
> loader, and the four built-in discipline predicates). Fields and predicates will grow;
|
|
7
|
+
> what is 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
|
+
## The file
|
|
15
|
+
|
|
16
|
+
Put exactly one of these at the project root:
|
|
17
|
+
|
|
18
|
+
| Filename | Note |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `polydeukes.config.yaml` | canonical |
|
|
21
|
+
| `polydeukes.config.yml` | accepted variant |
|
|
22
|
+
| `polydeukes.config.json` | accepted variant (read by the same parser — YAML is a JSON superset) |
|
|
23
|
+
|
|
24
|
+
Discovery is deliberately strict, and every failure refuses loudly instead of guessing:
|
|
25
|
+
|
|
26
|
+
- **No config found** → error naming all three candidate filenames. A missing config never
|
|
27
|
+
silently loads defaults — silent defaults would mean silently unprotected.
|
|
28
|
+
- **More than one found** → error naming the collisions. Ambiguity never picks a winner.
|
|
29
|
+
- **Parse error, or a custom YAML tag** → error naming the file. Custom tags are rejected
|
|
30
|
+
even though the parser cannot execute them — config data stays uncomputable by contract.
|
|
31
|
+
- **Schema violation** → error naming the key and the file. Unknown keys are rejected
|
|
32
|
+
wherever the core owns the vocabulary — the top level, and the fixed keys inside a
|
|
33
|
+
discipline entry — so `protectedPath:` for `protectedPaths:`, or `adaptors:` for
|
|
34
|
+
`adapters:`, is caught here. Two maps stay open, because their keys are your values
|
|
35
|
+
rather than the core's: language names under `languages`, and adapter names under
|
|
36
|
+
`adapters`. A misspelt adapter name is accepted and its block simply goes unread, which
|
|
37
|
+
leaves that adapter on its defaults — check the name against the adapter's own reference.
|
|
38
|
+
Inside a namespace the vocabulary belongs to that adapter: the core passes contents
|
|
39
|
+
through verbatim, and the adapter's own validator rejects what it does not recognise,
|
|
40
|
+
naming the full field path (see [`adapters`](#adapters-optional)).
|
|
41
|
+
|
|
42
|
+
## IDE support
|
|
43
|
+
|
|
44
|
+
The published JSON Schema gives autocompletion and validation in editors. The schema file
|
|
45
|
+
ships inside `@polydeukes/core` — a *transitive* dependency of the umbrella — and under
|
|
46
|
+
pnpm's default strict layout transitive dependencies are not exposed at your project's
|
|
47
|
+
top-level `node_modules` (measured 2026-08-03: the file resolves only under
|
|
48
|
+
`node_modules/.pnpm/…`). A `node_modules`-relative `$schema` line therefore does not
|
|
49
|
+
resolve in a default pnpm install. `pdks init claude-code` writes no `$schema` line into
|
|
50
|
+
the generated config for the same reason: no single *file-path* spelling resolves in every
|
|
51
|
+
consumer layout, and a wrong line loses editor validation silently.
|
|
52
|
+
|
|
53
|
+
Two forms work. The version-pinned public URL is independent of any install layout
|
|
54
|
+
(verified against the `v0.2.0` tag and `main` — swap the tag for the release you
|
|
55
|
+
installed):
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
# yaml-language-server: $schema=https://raw.githubusercontent.com/huskyhoochu/polydeukes/v0.2.0/packages/core/schema/polydeukes.schema.json
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The relative path works only in a layout where `@polydeukes/core` is present at the top
|
|
62
|
+
level of `node_modules`. Whether yours is one is a one-look check — if
|
|
63
|
+
`node_modules/@polydeukes/core/schema/` exists in your project, the line resolves:
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
# yaml-language-server: $schema=node_modules/@polydeukes/core/schema/polydeukes.schema.json
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
For a JSON config, use the standard top-level key instead — it is accepted and ignored by
|
|
70
|
+
the loader, with the same two values:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{ "$schema": "https://raw.githubusercontent.com/huskyhoochu/polydeukes/v0.2.0/packages/core/schema/polydeukes.schema.json" }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Reference
|
|
77
|
+
|
|
78
|
+
### `languages` (required)
|
|
79
|
+
|
|
80
|
+
The language axis, first-class. Keys are your values (`typescript`, `python`, …) — the
|
|
81
|
+
core ships no language names and never interprets the command string.
|
|
82
|
+
|
|
83
|
+
```yaml
|
|
84
|
+
languages:
|
|
85
|
+
typescript:
|
|
86
|
+
productionGlob: 'packages/*/src/**/*.ts' # what counts as production source
|
|
87
|
+
testCmd: 'pnpm --filter {scope} test' # {scope} is substituted at resolve time
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`testCmd` is a template string, not a function. Every `{scope}` occurrence is replaced;
|
|
91
|
+
all other braces (`${VAR}`, `{a,b}`, `awk '{print}'`) pass through untouched. A command
|
|
92
|
+
that ignores scope (`pnpm test`) is equally valid.
|
|
93
|
+
|
|
94
|
+
### `protectedPaths` (optional)
|
|
95
|
+
|
|
96
|
+
Raw path patterns whose files the covenants protect from modification — by editor tools
|
|
97
|
+
and by shell commands alike (`sed -i`, `tee`, redirects, heredocs, parent-directory
|
|
98
|
+
moves). Entries are normalized (trimmed, deduplicated) at resolve time.
|
|
99
|
+
|
|
100
|
+
```yaml
|
|
101
|
+
protectedPaths:
|
|
102
|
+
- 'packages/core/src'
|
|
103
|
+
- '.claude/hooks'
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**The config file protects itself.** The discovered config file is automatically appended
|
|
107
|
+
to `protectedPaths` — an edit that would lower your own gates goes through the same judge
|
|
108
|
+
as everything else. If the file that declares the disciplines were not itself under the
|
|
109
|
+
disciplines, the whole chain would be decoration.
|
|
110
|
+
|
|
111
|
+
### `adapters` (optional)
|
|
112
|
+
|
|
113
|
+
Adapter namespaces. One config file, one namespace per adapter: each key names an
|
|
114
|
+
adapter, and its value is that adapter's own settings object. The core validates the
|
|
115
|
+
container shape only — the keys and the contents belong to each adapter, which ships
|
|
116
|
+
its own validator for its own vocabulary. An unknown key *inside* a namespace is
|
|
117
|
+
rejected by that adapter's validator, with the full field path in the error.
|
|
118
|
+
|
|
119
|
+
```yaml
|
|
120
|
+
adapters:
|
|
121
|
+
git:
|
|
122
|
+
enforce: advise
|
|
123
|
+
protectedPaths:
|
|
124
|
+
- 'packages/core/src'
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
#### `adapters.git` — the git commit adapter
|
|
128
|
+
|
|
129
|
+
| Key | Values | Default | Meaning |
|
|
130
|
+
|---|---|---|---|
|
|
131
|
+
| `enforce` | `block` \| `advise` | `block` | Enforcement level of the commit surface |
|
|
132
|
+
| `protectedPaths` | string array | `[]` | Additive protection scope judged by the commit surface only |
|
|
133
|
+
|
|
134
|
+
- **`block`** — a staged change that breaks a covenant blocks the commit (exit 2). The
|
|
135
|
+
only way through is the witness valve: a human answering the TTY prompt with the full
|
|
136
|
+
token. The prompt names what it asks the human to witness — the broken registration,
|
|
137
|
+
the matched entry, and the fact that the one answer covers the whole commit. An absent
|
|
138
|
+
namespace, an absent `adapters` map, or an absent `enforce` key all mean `block` — not
|
|
139
|
+
writing the key selects the strictest level.
|
|
140
|
+
- **`advise`** — the commit surface becomes a backstop without a block: a verdict on a
|
|
141
|
+
staged change is recorded as an `advised` telemetry event and the commit proceeds
|
|
142
|
+
(exit 0) with one advisory line on stderr. No TTY prompt fires. Only the verdict is
|
|
143
|
+
relaxed — a run that cannot judge (missing or invalid config, an unresolvable judge
|
|
144
|
+
body) still fails closed at exit 2, at either level.
|
|
145
|
+
|
|
146
|
+
**`protectedPaths` here is an additive scope.** The commit surface judges the union of the
|
|
147
|
+
top-level `protectedPaths` and this list — concatenated (common first) and normalized as one,
|
|
148
|
+
so spelling and dedupe rules are identical for both. The session surface never reads it: the
|
|
149
|
+
list exists for paths whose edit is legitimate work during a session but must pass a judged
|
|
150
|
+
checkpoint when it is promoted into repository history — a judgment chain's own sources are
|
|
151
|
+
the canonical tenant. As the enforcement level is the observer's setting, so is the
|
|
152
|
+
additional scope. There is no subtractive vocabulary: a config line can widen a surface's
|
|
153
|
+
scope, never quietly strip one.
|
|
154
|
+
|
|
155
|
+
The session surface (the editor-time hook) has no level setting here; it always blocks.
|
|
156
|
+
|
|
157
|
+
**Context-family disciplines skip on the commit surface.** A commit has no session to look
|
|
158
|
+
at, so a `requirePrecedent` entry cannot be judged there — demanding evidence a commit
|
|
159
|
+
cannot carry would block every matching commit with no legitimate way through.
|
|
160
|
+
|
|
161
|
+
They are not filtered out, though. They assemble like any other discipline and become
|
|
162
|
+
*skip registrations*: routing intact, no judge body. When one matches a staged change it
|
|
163
|
+
records a `skipped` telemetry event and lets the commit proceed. The record carries the
|
|
164
|
+
entry's `id` and the change it would have judged, so a gate that did nothing says so in
|
|
165
|
+
the data — and it appears **only when the entry's scope actually matched**, so a commit
|
|
166
|
+
touching nothing the entry cares about records nothing at all.
|
|
167
|
+
|
|
168
|
+
This is the same disposition the session surface uses whenever it has no transcript to
|
|
169
|
+
read. One rule, both surfaces: evidence that cannot be evaluated is skipped and measured,
|
|
170
|
+
never blocked and never silent.
|
|
171
|
+
|
|
172
|
+
### `telemetry` (optional)
|
|
173
|
+
|
|
174
|
+
```yaml
|
|
175
|
+
telemetry:
|
|
176
|
+
logPath: '.polydeukes/roi.log' # default when omitted; keep it gitignored
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Every judgment — passed, blocked, witnessed, advised, or skipped — appends one record.
|
|
180
|
+
Telemetry is fail-open by design: a logging failure never changes a verdict.
|
|
181
|
+
|
|
182
|
+
### `witness` (optional)
|
|
183
|
+
|
|
184
|
+
```yaml
|
|
185
|
+
witness:
|
|
186
|
+
token: 'covenant witness' # the phrase a human types in the conversation
|
|
187
|
+
ttlMinutes: 10 # validity window, in minutes, from that message
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The values of the time-boxed human valve, consumed where the covenants are assembled.
|
|
191
|
+
The valve is sudo, not an exemption: the one property a deterministic gate can compute
|
|
192
|
+
about a judgment chain is "is an accountable human present, right now", and the witness
|
|
193
|
+
is that human supplying the pass condition in person. When a covenant blocks a
|
|
194
|
+
legitimate edit, a human types the agreed token into the conversation; blocked judgments
|
|
195
|
+
can be witnessed open for `ttlMinutes` from that message's timestamp, then blocking
|
|
196
|
+
resumes automatically. Both keys are required when the section is present: the token
|
|
197
|
+
must be non-empty after trimming, the window a finite number greater than zero.
|
|
198
|
+
|
|
199
|
+
**The valve stands after the verdict, never instead of it.** The judge body always runs.
|
|
200
|
+
A call that would have passed anyway never consults the valve, so an open window changes
|
|
201
|
+
nothing about clean work — and a `witnessed` telemetry row therefore always names a real
|
|
202
|
+
block a human answered for, never a ritual. Only a judgment that actually blocked can be
|
|
203
|
+
witnessed open.
|
|
204
|
+
|
|
205
|
+
**The token must stand alone on the message's first line.** Invoking the witness is
|
|
206
|
+
distinct from talking about it: a message that quotes, questions, or explains the token
|
|
207
|
+
mid-sentence — or wraps it in backticks — does not open the valve, while a first line
|
|
208
|
+
carrying the token alone does, with any following lines free for the work itself.
|
|
209
|
+
|
|
210
|
+
A message that invokes — the token alone on the first line, the rest free:
|
|
211
|
+
|
|
212
|
+
```text
|
|
213
|
+
covenant witness
|
|
214
|
+
|
|
215
|
+
now fix the hook file
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
A message that merely mentions — the valve stays shut:
|
|
219
|
+
|
|
220
|
+
```text
|
|
221
|
+
so when does `covenant witness` expire?
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The token's value is free — any phrase works, and it is never checked for a prefix or a
|
|
225
|
+
command shape. Only its placement is constrained.
|
|
226
|
+
|
|
227
|
+
The token is not a secret — the defense is provenance, not secrecy. A witness counts only
|
|
228
|
+
when the token arrives in a message positively identified as human-typed in the session
|
|
229
|
+
transcript, so an AI that knows the token still cannot forge one. Witnessed judgments are
|
|
230
|
+
recorded as `witnessed`, never silent.
|
|
231
|
+
|
|
232
|
+
### `disciplines` (optional)
|
|
233
|
+
|
|
234
|
+
Each entry is one discipline: a practice the team imposes on itself, declared as data.
|
|
235
|
+
An entry carries exactly **one** predicate (zero or two is rejected), an `id` (the
|
|
236
|
+
telemetry label), and optionally a `why` (the reason, kept next to the rule) plus, on a
|
|
237
|
+
`forbid` or `requirePrecedent` entry, `in` (the file globs it judges) and `except` (globs
|
|
238
|
+
carved out of that scope).
|
|
239
|
+
|
|
240
|
+
**`forbid` — content delta.** Blocks an edit that *adds* a new match of the pattern.
|
|
241
|
+
Existing occurrences are forgiven: adopting a discipline never blocks a legacy codebase,
|
|
242
|
+
because the judgment direction is "what did this edit add", not "what does the file
|
|
243
|
+
contain".
|
|
244
|
+
|
|
245
|
+
```yaml
|
|
246
|
+
disciplines:
|
|
247
|
+
- id: 'covenant-vocabulary'
|
|
248
|
+
why: 'control-framing vocabulary is banned in package sources.'
|
|
249
|
+
in:
|
|
250
|
+
- 'packages/*/src/**'
|
|
251
|
+
forbid: '\b(guard|harness|kb)\b'
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**`immutable` — path family.** Blocks modification of existing files that match; creating
|
|
255
|
+
new files is allowed.
|
|
256
|
+
|
|
257
|
+
```yaml
|
|
258
|
+
- id: 'archived-records-stay-frozen'
|
|
259
|
+
why: 'an archive that can be edited is not an archive.'
|
|
260
|
+
immutable: 'records/archive/**'
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
**`forbidCommand` — command family.** Blocks shell commands matching the pattern, even
|
|
264
|
+
when the command mentions no protected path. This is how gate-disarming commands are
|
|
265
|
+
caught.
|
|
266
|
+
|
|
267
|
+
```yaml
|
|
268
|
+
- id: 'hooks-stay-armed'
|
|
269
|
+
why: 'a command that disarms or reroutes the git gate is a gate bypass in itself.'
|
|
270
|
+
forbidCommand: 'LEFTHOOK=(0|false|no|off)\b|core\.hooksPath'
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
**`requirePrecedent` — context family.** Blocks a change that arrives without a required
|
|
274
|
+
step having happened earlier in the session. The other three families all ask "is this
|
|
275
|
+
change itself bad"; this one asks something else. The change is legitimate — what is
|
|
276
|
+
missing is the procedure in front of it, so what gets judged is not the mutation but the
|
|
277
|
+
session history.
|
|
278
|
+
|
|
279
|
+
Evidence means an **execution**, not a request. A call the covenant blocked, one a human
|
|
280
|
+
refused, and one that simply failed all leave the same trace in a session, and none of
|
|
281
|
+
them is precedent — the transcript is read for what actually ran and reported success.
|
|
282
|
+
That is what keeps the cheapest way through the gate being the thing the discipline
|
|
283
|
+
asks for.
|
|
284
|
+
|
|
285
|
+
Two consequences are worth knowing before you write one. The outcome is read per command
|
|
286
|
+
LINE, so a chain where the required command ran but a later step failed does not count.
|
|
287
|
+
And the pattern is matched at the start of a simple command, so the same words in an
|
|
288
|
+
argument or a comment do not count either. **In both cases running the command on its own
|
|
289
|
+
opens the gate** — the block message says so.
|
|
290
|
+
|
|
291
|
+
```yaml
|
|
292
|
+
- id: 'dependency-needs-npm-view'
|
|
293
|
+
why: 'a dependency version must be measured before it is written.'
|
|
294
|
+
in:
|
|
295
|
+
- 'package.json'
|
|
296
|
+
- 'packages/*/package.json'
|
|
297
|
+
when: '(^|\n)\s*"[^"]+"\s*:\s*"[~^]?\d[^"]*"'
|
|
298
|
+
requirePrecedent:
|
|
299
|
+
command: 'npm view '
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
The evidence vocabulary is layered. `command` is the core's own key — a shell call is a surface
|
|
303
|
+
every agent shares — and the core validates it fully, rejecting an empty string or a pattern that
|
|
304
|
+
does not compile. It is matched **at the start of a simple command**, not anywhere in the command
|
|
305
|
+
line, so `echo "npm view yaml"` and a mention parked behind a `#` are not evidence while `cd pkg &&
|
|
306
|
+
npm view yaml` is. Every other key belongs to an adapter: the core checks the container only (a flat
|
|
307
|
+
object carrying exactly one evidence key) and passes the value through verbatim, and the adapter
|
|
308
|
+
that owns the word validates and judges it. The Claude Code adapter brings two: `subagent` (exact
|
|
309
|
+
match on a spawn kind) and `tool` (a regex over tool names) — so "query the docs tool before
|
|
310
|
+
touching this" is expressible today. Both follow the same execution rule as `command`. An evidence
|
|
311
|
+
key no assembled adapter recognizes cannot be judged, so the entry compiles to a skip registration:
|
|
312
|
+
routing stays, the body is dropped, assembly names the fault once on stderr, and every matching
|
|
313
|
+
change afterwards records `skipped` rather than a verdict. A typo therefore never passes itself off
|
|
314
|
+
as adapter vocabulary — but it does leave the discipline inert, and the `skipped` rows are where
|
|
315
|
+
that shows.
|
|
316
|
+
|
|
317
|
+
`when` (optional) is the trigger: an added-direction delta regex, combinable with
|
|
318
|
+
`requirePrecedent` and with nothing else. When it is absent, every change inside `in`
|
|
319
|
+
scope triggers the discipline. The two keys divide the work — `in` says which files are
|
|
320
|
+
watched, `when` says which change in them demands the precedent.
|
|
321
|
+
|
|
322
|
+
**A caution on line anchors.** These patterns are matched against the file's whole content
|
|
323
|
+
as a single string, and the config schema takes a regex string with no flags. `^` therefore
|
|
324
|
+
anchors to the start of the *file*, not the start of a line, so a line-shaped pattern
|
|
325
|
+
written with `^` matches only the first line and the discipline silently stops firing —
|
|
326
|
+
the regex still compiles, the judgment still runs, and the verdict is `passed`. Write
|
|
327
|
+
`(^|\n)` when you mean the start of a line. This is why the example above carries
|
|
328
|
+
`(^|\n)\s*"[^"]+"…` rather than `^\s*"[^"]+"…`.
|
|
329
|
+
|
|
330
|
+
**And a caution on match length.** The delta keys on the matched *text*: a change is only
|
|
331
|
+
seen as added when the matched string itself differs between the file's before and after.
|
|
332
|
+
A pattern that stops mid-value — say at the first digit of a version — produces the same
|
|
333
|
+
match text for `4.0.5` and `4.0.6`, so a version bump adds nothing to the delta and the
|
|
334
|
+
discipline silently passes. Make the pattern span the whole value that can change; the
|
|
335
|
+
example above runs through the closing quote (`\d[^"]*"`) for exactly this reason. Both
|
|
336
|
+
failure shapes are the same class: the regex compiles, the verdict says `passed`, and
|
|
337
|
+
nothing tells you the discipline is inert — so when you add an entry, measure it against
|
|
338
|
+
a real file and a realistic edit, not a one-line snippet.
|
|
339
|
+
|
|
340
|
+
The kind of change matters at the trigger. With `when` present, a deletion never triggers
|
|
341
|
+
— deleting adds no content. With `when` absent, deletion triggers like any other change in
|
|
342
|
+
scope, since the declared scope is the whole mutation.
|
|
343
|
+
|
|
344
|
+
**The cheap way through is the honest one.** Unlike the witness, this evidence lives on the
|
|
345
|
+
AI's own surface, so it is not forgery-proof. It does not need to be: the least effortful
|
|
346
|
+
way to open this gate is to actually call the tool, and that is exactly the behaviour the
|
|
347
|
+
discipline exists to induce.
|
|
348
|
+
|
|
349
|
+
Adding a discipline is a data edit — no code, no plumbing. Custom judge bodies remain the
|
|
350
|
+
escape layer for the few rules data cannot express.
|
|
351
|
+
|
|
352
|
+
## What enforcement looks like
|
|
353
|
+
|
|
354
|
+
A violating tool call or shell command is **blocked (exit 2)** before it runs, with the
|
|
355
|
+
discipline's `id` in the telemetry record. The sanctioned valve is the witness — a human
|
|
356
|
+
supplying the pass condition on a judgment that actually blocked, recorded as
|
|
357
|
+
`witnessed` — never silent. On the commit surface under
|
|
358
|
+
`adapters.git.enforce: advise`, a verdict is recorded as `advised` and the commit
|
|
359
|
+
proceeds — a backstop that measures instead of blocking. A missing, ambiguous, or
|
|
360
|
+
invalid config blocks every call until it is fixed: the system fails closed, because a
|
|
361
|
+
dead gate that waves things through is the cheapest bypass of all.
|