polydeukes 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +16 -28
- package/README.md +16 -28
- package/dist/baseline.d.ts +37 -1
- package/dist/baseline.js +68 -1
- package/dist/bin.d.ts +3 -4
- package/dist/bin.js +73 -90
- package/dist/covenant/bash-line.d.ts +130 -0
- package/dist/covenant/bash-line.js +566 -0
- package/dist/covenant/declaration-engine.d.ts +108 -0
- package/dist/covenant/declaration-engine.js +415 -0
- package/dist/covenant/discipline.d.ts +109 -0
- package/dist/covenant/discipline.js +667 -0
- package/dist/covenant/dispatch.d.ts +156 -0
- package/dist/covenant/dispatch.js +250 -0
- package/dist/covenant/extract-steps.d.ts +122 -0
- package/dist/covenant/extract-steps.js +0 -0
- package/dist/covenant/mention.d.ts +117 -0
- package/dist/covenant/mention.js +209 -0
- package/dist/covenant/module.d.ts +25 -0
- package/dist/covenant/module.js +23 -0
- package/dist/covenant/mutation-rules.d.ts +40 -0
- package/dist/covenant/mutation-rules.js +146 -0
- package/dist/covenant/relations.d.ts +46 -0
- package/dist/covenant/relations.js +68 -0
- package/dist/covenant/run-covenant.d.ts +95 -0
- package/dist/covenant/run-covenant.js +164 -0
- package/dist/covenant/self-mod.d.ts +55 -0
- package/dist/covenant/self-mod.js +84 -0
- package/dist/covenant/shell-evidence.d.ts +38 -0
- package/dist/covenant/shell-evidence.js +247 -0
- package/dist/covenant/shell-mod.d.ts +84 -0
- package/dist/covenant/shell-mod.js +213 -0
- package/dist/covenant/supply.d.ts +54 -0
- package/dist/covenant/supply.js +50 -0
- package/dist/covenant/transcript-mod.d.ts +60 -0
- package/dist/covenant/transcript-mod.js +254 -0
- package/dist/covenant/ttl-witness.d.ts +47 -0
- package/dist/covenant/ttl-witness.js +80 -0
- package/dist/covenant-check.d.ts +74 -41
- package/dist/covenant-check.js +372 -129
- package/dist/diff-ir.d.ts +28 -0
- package/dist/diff-ir.js +310 -0
- package/dist/docs/README.ko.md +10 -9
- package/dist/docs/README.md +10 -9
- package/dist/docs/catalog.json +76 -28
- package/dist/docs/concepts/judgment.ko.md +2 -2
- package/dist/docs/concepts/judgment.md +2 -2
- package/dist/docs/how-to/configure-project.ko.md +16 -16
- package/dist/docs/how-to/configure-project.md +15 -12
- package/dist/docs/how-to/connect-surfaces.ko.md +67 -34
- package/dist/docs/how-to/connect-surfaces.md +67 -34
- package/dist/docs/how-to/write-disciplines.ko.md +64 -7
- package/dist/docs/how-to/write-disciplines.md +65 -7
- package/dist/docs/index.json +744 -362
- package/dist/docs/reference/cli/covenant-check.ko.md +112 -65
- package/dist/docs/reference/cli/covenant-check.md +114 -59
- package/dist/docs/reference/cli/docs.ko.md +2 -2
- package/dist/docs/reference/cli/docs.md +2 -2
- package/dist/docs/reference/cli/explain.ko.md +20 -15
- package/dist/docs/reference/cli/explain.md +22 -15
- package/dist/docs/reference/cli/init.ko.md +91 -52
- package/dist/docs/reference/cli/init.md +81 -55
- package/dist/docs/reference/configuration/index.ko.md +182 -71
- package/dist/docs/reference/configuration/index.md +188 -76
- package/dist/docs/reference/packages/adapter-claude-code.ko.md +39 -11
- package/dist/docs/reference/packages/adapter-claude-code.md +40 -14
- package/dist/docs/reference/packages/adapter-codex.ko.md +99 -0
- package/dist/docs/reference/packages/adapter-codex.md +100 -0
- package/dist/docs/reference/packages/adapter-grok.ko.md +81 -0
- package/dist/docs/reference/packages/adapter-grok.md +83 -0
- package/dist/docs/reference/packages/core.ko.md +50 -4
- package/dist/docs/reference/packages/core.md +55 -6
- package/dist/docs/reference/packages/polydeukes.ko.md +139 -77
- package/dist/docs/reference/packages/polydeukes.md +147 -81
- package/dist/docs/reference/packages/sdk-ts.ko.md +164 -0
- package/dist/docs/reference/packages/sdk-ts.md +167 -0
- package/dist/docs/troubleshooting.ko.md +51 -19
- package/dist/docs/troubleshooting.md +53 -17
- package/dist/docs/tutorials/first-judgment.ko.md +6 -4
- package/dist/docs/tutorials/first-judgment.md +6 -4
- package/dist/explain.d.ts +1 -1
- package/dist/explain.js +37 -22
- package/dist/load-config.d.ts +25 -1
- package/dist/load-config.js +21 -3
- package/dist/pre-state-reader.d.ts +1 -1
- package/dist/pre-state-reader.js +1 -1
- package/dist/scaffold-project.d.ts +2 -2
- package/dist/scaffold-project.js +12 -4
- package/dist/schema/polydeukes.schema.json +12 -0
- package/dist/worktree-reader.d.ts +19 -0
- package/dist/worktree-reader.js +30 -0
- package/package.json +5 -18
- package/dist/claude-code-hook.d.ts +0 -79
- package/dist/claude-code-hook.js +0 -372
- package/dist/claude-code.d.ts +0 -6
- package/dist/claude-code.js +0 -6
- package/dist/covenant-module.d.ts +0 -25
- package/dist/covenant-module.js +0 -42
- package/dist/docs/reference/packages/adapter-git.ko.md +0 -101
- package/dist/docs/reference/packages/adapter-git.md +0 -108
- package/dist/docs/reference/packages/covenant.ko.md +0 -115
- package/dist/docs/reference/packages/covenant.md +0 -123
- package/dist/index.d.ts +0 -22
- package/dist/index.js +0 -21
- package/dist/init-claude-code.d.ts +0 -52
- package/dist/init-claude-code.js +0 -586
- package/dist/init-grok.d.ts +0 -51
- package/dist/init-grok.js +0 -242
package/dist/covenant-check.js
CHANGED
|
@@ -1,46 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `pdks covenant check` — the
|
|
2
|
+
* `pdks covenant check` — the composition root both surfaces' callers reach through the bin.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the staged domain assembles; the other domains open no commit.
|
|
4
|
+
* The judged unit is the input IR the caller hands in; this root opens no repository. Assembly
|
|
5
|
+
* mirrors the session hook — loadConfig → normalizeProtectedPaths → dispatchCovenants — and
|
|
6
|
+
* runs the same judge bodies, so a change receives the verdict a session tool call would.
|
|
7
|
+
* Each toolCall is dispatched as its own input so telemetry stays one row per file.
|
|
9
8
|
*
|
|
10
|
-
* fail-closed: a missing config, an
|
|
11
|
-
* blocked record. An
|
|
9
|
+
* fail-closed: a missing config, an input that could not be produced, or an input carrying its
|
|
10
|
+
* own `world` exits 2 with one blocked record. An input with no toolCalls is an explicit pass
|
|
11
|
+
* with no records.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
* never re-prompts.
|
|
26
|
-
*/
|
|
27
|
-
function ttyWitnessValve(witness, ttyPrompt) {
|
|
28
|
-
if (witness === undefined || ttyPrompt === undefined)
|
|
29
|
-
return undefined;
|
|
30
|
-
const token = witness.token.trim();
|
|
31
|
-
let verdict;
|
|
32
|
-
return (_input, _transcript, context) => {
|
|
33
|
-
if (verdict === undefined) {
|
|
34
|
-
const prompt = `covenant: '${context.label}' broke on the staged change matching '${context.subject}'.\n` +
|
|
35
|
-
'answering opens the valve for the whole commit, not just this change.\n' +
|
|
36
|
-
'type the agreed token in full to open it (enter to refuse): ';
|
|
37
|
-
verdict = false;
|
|
38
|
-
const answer = ttyPrompt(prompt);
|
|
39
|
-
verdict = answer !== null && answer.trim() === token;
|
|
40
|
-
}
|
|
41
|
-
return verdict;
|
|
42
|
-
};
|
|
43
|
-
}
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { join, resolve } from 'node:path';
|
|
16
|
+
import { appendRecordFailOpen, DEFAULT_TELEMETRY_LOG_PATH, isPlainObject, normalizeProtectedPaths, transcriptFromSession, } from '@polydeukes/core';
|
|
17
|
+
import { compareBaseline, updateBaseline } from './baseline.js';
|
|
18
|
+
import { relativizeForScope } from './covenant/discipline.js';
|
|
19
|
+
import { covenantModule } from './covenant/module.js';
|
|
20
|
+
import { ttlWitness } from './covenant/ttl-witness.js';
|
|
21
|
+
import { STAGED_DELETE, STAGED_WRITE } from './diff-ir.js';
|
|
22
|
+
import { discoverConfigPath, parseConfigSource } from './load-config.js';
|
|
23
|
+
import { sessionPreStateReader, unobservedPreStateReader } from './pre-state-reader.js';
|
|
24
|
+
import { worktreeReader } from './worktree-reader.js';
|
|
44
25
|
/**
|
|
45
26
|
* One blocked record for a run that failed closed before any dispatch could judge.
|
|
46
27
|
* `appendRecordFailOpen` creates the missing `.polydeukes/` of a never-judged repository,
|
|
@@ -57,151 +38,248 @@ function recordFailClosed(telemetryPath) {
|
|
|
57
38
|
});
|
|
58
39
|
}
|
|
59
40
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
41
|
+
* This runner's registration set — one assembly that the runner dispatches and `explain`
|
|
42
|
+
* renders.
|
|
43
|
+
*
|
|
44
|
+
* The surface picks the discipline lists; the two IR keys shape the meta-covenants and
|
|
45
|
+
* nothing else — the roster says which names route to which meta-covenant, and the session
|
|
46
|
+
* says whether there is history to protect, to bind, and to read pre-state from disk for.
|
|
62
47
|
*/
|
|
63
|
-
export function
|
|
64
|
-
const { config, rootDir, covenant, witness } = spec;
|
|
65
|
-
const { protectedPaths:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
48
|
+
export function assembleCheckRegistrations(spec) {
|
|
49
|
+
const { config, rootDir, covenant, surface, tools, session, transcript, witness } = spec;
|
|
50
|
+
const protectedPaths = normalizeProtectedPaths({ protectedPaths: config.protectedPaths ?? [] });
|
|
51
|
+
// The shared list first, so both surfaces read the same prefix in the rows and in
|
|
52
|
+
// `pdks explain`.
|
|
53
|
+
const surfaceDisciplines = surface === 'session' ? config.sessionDisciplines : config.changeSetDisciplines;
|
|
54
|
+
const disciplines = [...(config.disciplines ?? []), ...(surfaceDisciplines ?? [])];
|
|
55
|
+
const shellTools = tools?.shell ?? [];
|
|
56
|
+
const commandArgs = tools?.commandArgs ?? [];
|
|
57
|
+
const evidencePath = session?.evidencePath;
|
|
58
|
+
// An EMPTY roster is a host that declared it has no mutating tool, so only an ABSENT
|
|
59
|
+
// `tools` falls back to the staged names — a length check would route names no host call
|
|
60
|
+
// carries. The declared emptiness leaves no tool axis to register, the same way an empty
|
|
61
|
+
// shell roster leaves no shell axis.
|
|
62
|
+
const mutatingToolNames = tools?.mutating ?? [STAGED_WRITE, STAGED_DELETE];
|
|
74
63
|
const registrations = [
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
64
|
+
...(mutatingToolNames.length === 0
|
|
65
|
+
? []
|
|
66
|
+
: [covenant.selfModRegistration({ protectedPaths, mutatingToolNames, witness })]),
|
|
67
|
+
// No shell tool is no shell axis, not a shell axis over nothing: registering it with an
|
|
68
|
+
// empty roster would judge a command line the host said it has no tool for.
|
|
69
|
+
...(shellTools.length === 0
|
|
70
|
+
? []
|
|
71
|
+
: [covenant.shellModRegistration({ protectedPaths, shellTools, commandArgs, witness })]),
|
|
72
|
+
// The evidence file is what the witness and every history declaration read, so a call
|
|
73
|
+
// rewriting it could forge its own permission. Nothing to protect where the host named
|
|
74
|
+
// no evidence path.
|
|
75
|
+
...(evidencePath === undefined
|
|
76
|
+
? []
|
|
77
|
+
: [
|
|
78
|
+
covenant.transcriptModRegistration({
|
|
79
|
+
transcriptPath: evidencePath,
|
|
80
|
+
// The env value first, since that is what the judged shell expands `~` and
|
|
81
|
+
// `$HOME` from; `homedir()` reads the same passwd entry bash falls back to when
|
|
82
|
+
// HOME is unset.
|
|
83
|
+
home: process.env.HOME ?? homedir(),
|
|
84
|
+
shellTools,
|
|
85
|
+
commandArgs,
|
|
86
|
+
mutatingTools: mutatingToolNames,
|
|
87
|
+
witness,
|
|
88
|
+
}),
|
|
89
|
+
]),
|
|
80
90
|
...covenant.compileDisciplineRegistrations({
|
|
81
91
|
disciplines,
|
|
82
92
|
rootDir,
|
|
83
|
-
shellTools
|
|
84
|
-
commandArgs
|
|
85
|
-
|
|
86
|
-
|
|
93
|
+
shellTools,
|
|
94
|
+
commandArgs,
|
|
95
|
+
// A session call is judged before its tool runs, so the working tree IS the pre-state;
|
|
96
|
+
// without one the input carries the pre its own observation saw.
|
|
97
|
+
readPreState: session === undefined ? unobservedPreStateReader : sessionPreStateReader,
|
|
98
|
+
// That reader answers nothing, so the surface has no pre-state channel to complete a
|
|
99
|
+
// shell write's evidence with. Saying so keeps the absence an environment fact: a
|
|
100
|
+
// reader that answers `undefined` per location means that location failed, which
|
|
101
|
+
// blocks, and a shell call would then decide entries that never read its evidence.
|
|
102
|
+
observesPreState: session !== undefined,
|
|
103
|
+
// A diff-translated change carries the hunk's added lines as `post`, never the whole
|
|
104
|
+
// file, so a `file` binding on a staged path reads the tree — the state the change
|
|
105
|
+
// set left. A session call's `post` is the whole text it is about to write.
|
|
106
|
+
postIsWholeFile: surface === 'session',
|
|
87
107
|
witness,
|
|
108
|
+
// The session itself, injected rather than its path: a declaration reading a
|
|
109
|
+
// `transcript` binding sees it flattened, and its absence is the absence of a session.
|
|
110
|
+
transcript,
|
|
88
111
|
}),
|
|
89
112
|
];
|
|
90
113
|
return registrations;
|
|
91
114
|
}
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
115
|
+
/**
|
|
116
|
+
* The name `explain` renders the change-set surface from — the same assembly, called with
|
|
117
|
+
* `surface: 'changeSet'` and neither IR key.
|
|
118
|
+
*/
|
|
119
|
+
export const assembleChangeSetRegistrations = assembleCheckRegistrations;
|
|
120
|
+
/** The message a thrown value carries, for a stderr line. */
|
|
121
|
+
function messageOf(error) {
|
|
122
|
+
return error instanceof Error ? error.message : String(error);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* One stage's failure disposition: the stderr line, the recorded row, and exit 2. `suffix`
|
|
126
|
+
* is appended to the message, for a caller that can name the one call which would clear the
|
|
127
|
+
* failure.
|
|
128
|
+
*/
|
|
129
|
+
function failClosed(telemetryPath, error, suffix) {
|
|
130
|
+
process.stderr.write(`covenant check failed closed: ${messageOf(error)}${suffix ?? ''}\n`);
|
|
95
131
|
recordFailClosed(telemetryPath);
|
|
96
132
|
return { exitCode: 2 };
|
|
97
133
|
}
|
|
134
|
+
/** The observation, from the caller's value or from the thunk that produces it. */
|
|
135
|
+
function readInput(spec) {
|
|
136
|
+
return typeof spec.input === 'function' ? spec.input() : spec.input;
|
|
137
|
+
}
|
|
98
138
|
/**
|
|
99
139
|
* Settle the telemetry path and load the config once, or fail closed. The provisional
|
|
100
140
|
* path is settled before the load so a config that never loads still has somewhere to
|
|
101
141
|
* write its blocked row; both terms use `resolve` so a relative `repoRoot` cannot send
|
|
102
142
|
* them to different files. The provisional term sits inside the try because `resolve`
|
|
103
143
|
* throws on a non-string `repoRoot`.
|
|
144
|
+
*
|
|
145
|
+
* The load runs as its three steps rather than through `loadConfig`, so a failure carries
|
|
146
|
+
* how far it got: which file was discovered, and the bytes that file held.
|
|
104
147
|
*/
|
|
105
148
|
function settleConfig(spec) {
|
|
106
149
|
let telemetryPath;
|
|
150
|
+
let configPath;
|
|
151
|
+
let source;
|
|
107
152
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
153
|
+
// The environment variable sits between the caller's path and the config's, matching
|
|
154
|
+
// what the baseline comparison in this same process already resolves — the two write
|
|
155
|
+
// to one log, so they must agree on which one. It is how a test run collects its own
|
|
156
|
+
// rows without editing the config it is measuring.
|
|
157
|
+
const envPath = process.env.POLYDEUKES_TELEMETRY_PATH;
|
|
158
|
+
telemetryPath =
|
|
159
|
+
spec.telemetryPath ?? envPath ?? resolve(spec.repoRoot, DEFAULT_TELEMETRY_LOG_PATH);
|
|
160
|
+
configPath = discoverConfigPath({ rootDir: spec.repoRoot });
|
|
161
|
+
source = readFileSync(join(spec.repoRoot, configPath), 'utf-8');
|
|
162
|
+
const { config } = parseConfigSource({ source, configPath });
|
|
163
|
+
telemetryPath =
|
|
164
|
+
spec.telemetryPath ?? envPath ?? resolve(spec.repoRoot, config.telemetry.logPath);
|
|
111
165
|
return { settled: true, telemetryPath, config };
|
|
112
166
|
}
|
|
113
167
|
catch (error) {
|
|
114
|
-
return {
|
|
168
|
+
return {
|
|
169
|
+
settled: false,
|
|
170
|
+
telemetryPath,
|
|
171
|
+
error,
|
|
172
|
+
...(configPath === undefined ? {} : { configPath }),
|
|
173
|
+
...(source === undefined ? {} : { source }),
|
|
174
|
+
};
|
|
115
175
|
}
|
|
116
176
|
}
|
|
117
177
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*/
|
|
121
|
-
function collectDomain(repoRoot, domain) {
|
|
122
|
-
if (domain.kind === 'worktree')
|
|
123
|
-
return collectWorktreeChanges({ repoRoot });
|
|
124
|
-
if (domain.kind === 'range') {
|
|
125
|
-
const separator = domain.ancestry === 'merge-base' ? '...' : '..';
|
|
126
|
-
return collectRangeChanges({
|
|
127
|
-
repoRoot,
|
|
128
|
-
range: `${domain.base}${separator}${domain.head}`,
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
return collectStagedChanges({ repoRoot });
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* The observation's change set: the paths of the collected changes that carry file-change
|
|
135
|
-
* evidence, in collection order.
|
|
178
|
+
* The observation's change set: the paths of the input's toolCalls that carry file-change
|
|
179
|
+
* evidence, in input order.
|
|
136
180
|
*
|
|
137
181
|
* The same definition the judge derives its own set from, so both surfaces name the same
|
|
138
|
-
* changes. A deletion carries evidence and stays; a binary blob, which
|
|
139
|
-
*
|
|
140
|
-
*
|
|
182
|
+
* changes. A deletion carries evidence and stays; a binary blob, which arrives as a call
|
|
183
|
+
* with no evidence, produces no world of its own — listing it would hand the change-set
|
|
184
|
+
* relations a path no world can ever answer for.
|
|
141
185
|
*/
|
|
142
|
-
function changedPaths(
|
|
186
|
+
function changedPaths(input) {
|
|
143
187
|
const paths = [];
|
|
144
|
-
for (const call of
|
|
188
|
+
for (const call of input.toolCalls) {
|
|
145
189
|
if (call.fileChange !== undefined)
|
|
146
190
|
paths.push(call.fileChange.path);
|
|
147
191
|
}
|
|
148
192
|
return paths;
|
|
149
193
|
}
|
|
150
194
|
/**
|
|
151
|
-
* Assemble the registrations and dispatch every
|
|
152
|
-
*
|
|
195
|
+
* Assemble the registrations and dispatch every toolCall. Any throw here (a
|
|
196
|
+
* registration-build failure) is unjudgeable: block and leave one record.
|
|
153
197
|
*/
|
|
154
|
-
async function
|
|
198
|
+
async function judgeInput(spec, telemetryPath, config, input) {
|
|
155
199
|
try {
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
// judges the session hook does; tests inject a directory instead. Awaited before any
|
|
160
|
-
// registration is composed, so a dist the barrel cannot load fails the run closed here
|
|
161
|
-
// rather than leaving a half-judged table behind.
|
|
162
|
-
const covenantDist = spec.covenantDist ?? resolveCovenantDist();
|
|
163
|
-
const covenant = await loadCovenantModule(covenantDist);
|
|
164
|
-
// No valve under advise (nothing to witness) and none outside `staged`.
|
|
165
|
-
const witness = enforce === 'advise' || domain.kind !== 'staged'
|
|
166
|
-
? undefined
|
|
167
|
-
: ttyWitnessValve(config.witness, spec.ttyPrompt);
|
|
200
|
+
// The umbrella's own judge module, so this runner runs the judges the session
|
|
201
|
+
// hook does; a test injects a module with one member replaced.
|
|
202
|
+
const covenant = spec.covenant ?? covenantModule;
|
|
168
203
|
let blocked = false;
|
|
169
204
|
let advisedCount = 0;
|
|
170
|
-
|
|
205
|
+
const { tools, session } = input;
|
|
206
|
+
// One witness predicate shared by every registration: a witness is a session-wide
|
|
207
|
+
// permission the human granted, not a per-covenant one. It exists only where a session
|
|
208
|
+
// does — the valve reads human utterances, and an input with no session has none to
|
|
209
|
+
// read — so a session-free input assembles exactly the registrations it did before.
|
|
210
|
+
const witness = config.witness === undefined || session === undefined
|
|
211
|
+
? undefined
|
|
212
|
+
: ttlWitness({
|
|
213
|
+
token: config.witness.token,
|
|
214
|
+
// Minutes are the human-facing unit in config; the predicate takes milliseconds.
|
|
215
|
+
ttlMs: config.witness.ttlMinutes * 60_000,
|
|
216
|
+
});
|
|
217
|
+
const transcript = session === undefined ? undefined : transcriptFromSession(session);
|
|
218
|
+
// Assembled ONCE for the run, not per call: a judge takes its call set as an argument,
|
|
171
219
|
// so the table is payload-free. Recompiling per file would repeat every compile-time
|
|
172
220
|
// side effect — the stderr line a config-faulted discipline names itself with would
|
|
173
221
|
// print once per staged file rather than once.
|
|
174
|
-
const registrations =
|
|
222
|
+
const registrations = assembleCheckRegistrations({
|
|
175
223
|
config,
|
|
176
224
|
rootDir: spec.repoRoot,
|
|
177
225
|
covenant,
|
|
226
|
+
surface: spec.surface,
|
|
227
|
+
tools,
|
|
228
|
+
session,
|
|
229
|
+
transcript,
|
|
178
230
|
witness,
|
|
179
231
|
});
|
|
180
|
-
// One plan and one supply for the run: the per-
|
|
181
|
-
// read once per named file rather than once per change.
|
|
182
|
-
// observation because this surface dispatches one
|
|
232
|
+
// One plan and one supply for the run: the per-call loop shares them, so the tree is
|
|
233
|
+
// read once per named file rather than once per change. The change set carries the whole
|
|
234
|
+
// observation because this surface dispatches one call at a time to keep telemetry at
|
|
183
235
|
// one row per file — a set no judge could derive from the input it is handed.
|
|
184
236
|
const { files } = covenant.supplySources({
|
|
185
237
|
plan: covenant.planSources({ registrations }),
|
|
186
|
-
read:
|
|
238
|
+
read: worktreeReader({ repoRoot: spec.repoRoot }),
|
|
187
239
|
});
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
240
|
+
// The session's channels ride into the world here rather than through a reader: the
|
|
241
|
+
// host already observed them, and this root opens no file the input did not name. An
|
|
242
|
+
// input with no session carries no channels key at all — an absent channel is a
|
|
243
|
+
// different fact from a channel that observed nothing.
|
|
244
|
+
const channels = session?.channels;
|
|
245
|
+
const world = {
|
|
246
|
+
files,
|
|
247
|
+
changes: changedPaths(input),
|
|
248
|
+
...(channels === undefined ? {} : { channels }),
|
|
249
|
+
};
|
|
250
|
+
for (const call of input.toolCalls) {
|
|
191
251
|
const { exitCode, results } = await covenant.dispatchCovenants({
|
|
192
|
-
stdinPayload: JSON.stringify(
|
|
252
|
+
stdinPayload: JSON.stringify({
|
|
253
|
+
toolCalls: [call],
|
|
254
|
+
subagentSpawns: input.subagentSpawns,
|
|
255
|
+
userMessages: input.userMessages,
|
|
256
|
+
// Neither the roster nor the session travels with the call: the roster is spent at
|
|
257
|
+
// assembly (no judge body reads it), and the session is injected as the transcript,
|
|
258
|
+
// because a dispatch's input is one judged unit rather than the session it happened in.
|
|
259
|
+
...(input.actor === undefined ? {} : { actor: input.actor }),
|
|
260
|
+
}),
|
|
193
261
|
registrations,
|
|
194
262
|
telemetryPath,
|
|
195
263
|
dispatcherLabel: 'covenant-check',
|
|
196
|
-
enforce,
|
|
264
|
+
enforce: spec.enforce ?? 'advise',
|
|
265
|
+
transcript,
|
|
197
266
|
world,
|
|
198
267
|
});
|
|
199
268
|
if (exitCode === 2)
|
|
200
269
|
blocked = true;
|
|
201
270
|
advisedCount += results.filter((result) => result.event === 'advised').length;
|
|
271
|
+
// One call, one record: a call no registration routed leaves no row of its own, so
|
|
272
|
+
// the runner writes the pass under its label — the session surface does the same.
|
|
273
|
+
if (exitCode === 0 && results.length === 0) {
|
|
274
|
+
const subject = call.args?.file_path;
|
|
275
|
+
appendRecordFailOpen(telemetryPath, {
|
|
276
|
+
event: 'passed',
|
|
277
|
+
label: 'covenant-check',
|
|
278
|
+
subject: typeof subject === 'string' ? subject : '-',
|
|
279
|
+
});
|
|
280
|
+
}
|
|
202
281
|
}
|
|
203
|
-
// Names no level:
|
|
204
|
-
// fate is read from the run.
|
|
282
|
+
// Names no level: the commit's fate is read from the run.
|
|
205
283
|
if (advisedCount > 0) {
|
|
206
284
|
const outcome = blocked ? 'commit blocked by another verdict' : 'commit allowed';
|
|
207
285
|
process.stderr.write(`covenant advisory: ${advisedCount} verdict(s) recorded as advised, ${outcome}\n`);
|
|
@@ -213,24 +291,189 @@ async function judgeChanges(spec, domain, telemetryPath, config, changes) {
|
|
|
213
291
|
}
|
|
214
292
|
}
|
|
215
293
|
/**
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
294
|
+
* Reject an input whose roster or session evidence this runner cannot judge.
|
|
295
|
+
*
|
|
296
|
+
* A shape it cannot read is a block, never a default: folding a missing collection into
|
|
297
|
+
* `[]` or a bare string into a one-element list would judge an input nobody wrote. A key
|
|
298
|
+
* whose value is `undefined` reads as absent — a JSON round-trip drops it, so only an
|
|
299
|
+
* in-process caller can even write that shape.
|
|
300
|
+
*/
|
|
301
|
+
function assertJudgeableShape(input) {
|
|
302
|
+
const { tools, session } = input;
|
|
303
|
+
if (tools !== undefined) {
|
|
304
|
+
if (!isPlainObject(tools))
|
|
305
|
+
throw new Error('input carries a tools key that is not an object');
|
|
306
|
+
for (const key of ['mutating', 'shell', 'commandArgs']) {
|
|
307
|
+
const names = tools[key];
|
|
308
|
+
if (!Array.isArray(names))
|
|
309
|
+
throw new Error(`input carries no tools.${key} array`);
|
|
310
|
+
// A roster names tools; a non-string entry is a name no call could ever carry, and
|
|
311
|
+
// routing on it would silently leave that slot of the roster unjudged.
|
|
312
|
+
if (!names.every((name) => typeof name === 'string')) {
|
|
313
|
+
throw new Error(`input carries a non-string name in tools.${key}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// A shell tool with no argument key is a roster the shell judge cannot read: every
|
|
317
|
+
// routed call would land unjudgeable, which no posture softens. Refuse the run instead.
|
|
318
|
+
if (tools.shell.length > 0 && tools.commandArgs.length === 0) {
|
|
319
|
+
throw new Error('input carries tools.shell without a tools.commandArgs key to read');
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (session === undefined)
|
|
323
|
+
return;
|
|
324
|
+
if (!isPlainObject(session)) {
|
|
325
|
+
throw new Error('input carries a session key that is not an object');
|
|
326
|
+
}
|
|
327
|
+
for (const key of ['userMessages', 'toolCalls']) {
|
|
328
|
+
if (!Array.isArray(session[key]))
|
|
329
|
+
throw new Error(`input carries no session.${key} array`);
|
|
330
|
+
}
|
|
331
|
+
if (session.evidencePath !== undefined && typeof session.evidencePath !== 'string') {
|
|
332
|
+
throw new Error('input carries a session.evidencePath that is not a string');
|
|
333
|
+
}
|
|
334
|
+
if (session.channels !== undefined && !isPlainObject(session.channels)) {
|
|
335
|
+
throw new Error('input carries a session.channels that is not an object');
|
|
336
|
+
}
|
|
337
|
+
const sidecar = session.channels?.sidecar;
|
|
338
|
+
if (sidecar !== undefined && typeof sidecar !== 'string') {
|
|
339
|
+
throw new Error('input carries a session.channels.sidecar that is not text');
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* The disposition of a run whose config never loaded.
|
|
344
|
+
*
|
|
345
|
+
* While the one discovered file does not load there is no assembly to judge against, so the
|
|
346
|
+
* session surface admits exactly one shape: a single tool call whose file-change evidence
|
|
347
|
+
* modifies that file, starting from the bytes the loader read, and whose `post` is a text the
|
|
348
|
+
* loader accepts. That call is judged — its result loads — and lands as one `advised` row
|
|
349
|
+
* naming the config path, so the next call's baseline comparison reads the change as
|
|
350
|
+
* explained. Everything else fails closed, and where a single file was discovered the line
|
|
351
|
+
* says which call would repair it.
|
|
352
|
+
*
|
|
353
|
+
* This branch reads no posture: the session hook always spawns with `--enforce block`, so a
|
|
354
|
+
* repair that blocked under that posture would never run anywhere.
|
|
355
|
+
*/
|
|
356
|
+
function settleLoadFailure(spec, failure) {
|
|
357
|
+
const { telemetryPath, error, configPath } = failure;
|
|
358
|
+
if (configPath === undefined || spec.surface !== 'session') {
|
|
359
|
+
return failClosed(telemetryPath, error);
|
|
360
|
+
}
|
|
361
|
+
let input;
|
|
362
|
+
try {
|
|
363
|
+
input = readInput(spec);
|
|
364
|
+
}
|
|
365
|
+
catch (inputError) {
|
|
366
|
+
return failClosed(telemetryPath, inputError);
|
|
367
|
+
}
|
|
368
|
+
const loaded = repairs(input, failure, spec.repoRoot);
|
|
369
|
+
if (loaded !== null) {
|
|
370
|
+
// The repaired config's own log path, under the precedence `settleConfig` uses, so the
|
|
371
|
+
// next call's baseline comparison reads this row where it looks for it.
|
|
372
|
+
const rowPath = spec.telemetryPath ??
|
|
373
|
+
process.env.POLYDEUKES_TELEMETRY_PATH ??
|
|
374
|
+
resolve(spec.repoRoot, loaded.config.telemetry.logPath);
|
|
375
|
+
appendRecordFailOpen(rowPath, {
|
|
376
|
+
event: 'advised',
|
|
377
|
+
label: 'covenant-check',
|
|
378
|
+
subject: configPath,
|
|
379
|
+
});
|
|
380
|
+
process.stderr.write(`covenant check: ${configPath} does not load (${messageOf(error)}) — this call rewrites it into one that does; advised, not judged\n`);
|
|
381
|
+
return { exitCode: 0 };
|
|
382
|
+
}
|
|
383
|
+
return failClosed(telemetryPath, error, ` — fix ${configPath} in one Edit or Write whose result loads; every other call stays blocked until it does`);
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* The loaded config a single call would leave behind, or null when this observation is not
|
|
387
|
+
* that call: exactly one call carrying a plain object, its evidence a modification of the
|
|
388
|
+
* discovered config file (relativized against the root, since a host names its paths
|
|
389
|
+
* absolutely), starting from the bytes the loader read, and leaving a `post` the loader
|
|
390
|
+
* accepts. Requiring the pre to be those bytes keeps a partial view of the file — one
|
|
391
|
+
* notebook cell, or evidence a caller composed — out of the branch.
|
|
392
|
+
*/
|
|
393
|
+
function repairs(input, failure, repoRoot) {
|
|
394
|
+
const { configPath, source } = failure;
|
|
395
|
+
if (configPath === undefined || source === undefined)
|
|
396
|
+
return null;
|
|
397
|
+
if (!Array.isArray(input.toolCalls) || input.toolCalls.length !== 1)
|
|
398
|
+
return null;
|
|
399
|
+
const call = input.toolCalls[0];
|
|
400
|
+
if (!isPlainObject(call))
|
|
401
|
+
return null;
|
|
402
|
+
const { fileChange } = call;
|
|
403
|
+
if (fileChange === undefined || fileChange.kind !== 'modify')
|
|
404
|
+
return null;
|
|
405
|
+
if (relativizeForScope(fileChange.path, repoRoot) !== configPath)
|
|
406
|
+
return null;
|
|
407
|
+
if (fileChange.pre !== source)
|
|
408
|
+
return null;
|
|
409
|
+
const post = fileChange.post;
|
|
410
|
+
if (typeof post !== 'string')
|
|
411
|
+
return null;
|
|
412
|
+
try {
|
|
413
|
+
return parseConfigSource({ source: post, configPath });
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Judge one observation of `repoRoot` exactly as the session surface would, from the IR the
|
|
421
|
+
* caller hands in. Async because the dispatcher spawns covenant bodies. An input with no
|
|
422
|
+
* toolCalls is an explicit pass: nothing to judge, no records.
|
|
423
|
+
*
|
|
424
|
+
* An input carrying a session is one call of a live agent session, so the post-hoc state
|
|
425
|
+
* comparison wraps the judgment: it runs first, reading the window the previous call left,
|
|
426
|
+
* and the re-establishment runs last, folding this call's own judged writes in. Both sides
|
|
427
|
+
* sit OUTSIDE the judgment with their own catch — a mechanism whose purpose is to record
|
|
428
|
+
* rather than stop may never reach an exit code.
|
|
219
429
|
*/
|
|
220
430
|
export async function runCovenantCheck(spec) {
|
|
221
431
|
const settlement = settleConfig(spec);
|
|
222
432
|
if (!settlement.settled)
|
|
223
|
-
return
|
|
433
|
+
return settleLoadFailure(spec, settlement);
|
|
224
434
|
const { telemetryPath, config } = settlement;
|
|
225
|
-
|
|
226
|
-
let changes;
|
|
435
|
+
let input;
|
|
227
436
|
try {
|
|
228
|
-
|
|
437
|
+
input = readInput(spec);
|
|
438
|
+
// The world axis is this root's to fill. An input that supplies its own would let a
|
|
439
|
+
// caller choose the files the judge reads.
|
|
440
|
+
if ('world' in input) {
|
|
441
|
+
throw new Error('input carries a world key: the world axis is the runner’s');
|
|
442
|
+
}
|
|
443
|
+
if (!Array.isArray(input.toolCalls))
|
|
444
|
+
throw new Error('input carries no toolCalls array');
|
|
445
|
+
assertJudgeableShape(input);
|
|
229
446
|
}
|
|
230
447
|
catch (error) {
|
|
231
448
|
return failClosed(telemetryPath, error);
|
|
232
449
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
450
|
+
// The comparison reads and writes the log the judgment writes — the settled path, never
|
|
451
|
+
// a second resolution — and observes the protected entries the settled config names.
|
|
452
|
+
// A session call with nothing judgeable still compares and re-establishes: the window it
|
|
453
|
+
// opens is what the next call reads, and a skipped call would leave it stale.
|
|
454
|
+
let comparison;
|
|
455
|
+
if (input.session !== undefined) {
|
|
456
|
+
try {
|
|
457
|
+
comparison = {
|
|
458
|
+
repoRoot: spec.repoRoot,
|
|
459
|
+
telemetryPath,
|
|
460
|
+
entries: normalizeProtectedPaths({ protectedPaths: config.protectedPaths }),
|
|
461
|
+
};
|
|
462
|
+
compareBaseline(comparison);
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
// fail-open: a comparison that could not run leaves the judgment exactly as it was.
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const result = input.toolCalls.length === 0
|
|
469
|
+
? { exitCode: 0 }
|
|
470
|
+
: await judgeInput(spec, telemetryPath, config, input);
|
|
471
|
+
try {
|
|
472
|
+
if (comparison !== undefined)
|
|
473
|
+
updateBaseline(comparison);
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
// fail-open: an unwritable baseline costs the next call's detection, never this verdict.
|
|
477
|
+
}
|
|
478
|
+
return result;
|
|
236
479
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* unified diff → `CovenantInput`. One pure translation, no judgment and no disk.
|
|
3
|
+
*
|
|
4
|
+
* The accepted grammar is finite: `diff --git` blocks and header-less `---`/`+++` blocks,
|
|
5
|
+
* covering creation, deletion, modification, mode-only changes, renames, and binary blobs.
|
|
6
|
+
* Anything else throws, so an unrecognised shape fails the run closed instead of translating
|
|
7
|
+
* to a partial observation.
|
|
8
|
+
*
|
|
9
|
+
* A modification's `pre`/`post` are the hunk's `-` and `+` lines, never the whole file: a
|
|
10
|
+
* unified diff carries the changed lines and the context around them, and reconstructing the
|
|
11
|
+
* file from them is not a translation. A creation and a deletion carry the whole text because
|
|
12
|
+
* every line of the file is in the hunk.
|
|
13
|
+
*/
|
|
14
|
+
import type { CovenantInput } from '@polydeukes/core';
|
|
15
|
+
/** The tool name a staged write is dispatched under — telemetry and configs read it. */
|
|
16
|
+
export declare const STAGED_WRITE = "staged-write";
|
|
17
|
+
/** The tool name a staged deletion is dispatched under. */
|
|
18
|
+
export declare const STAGED_DELETE = "staged-delete";
|
|
19
|
+
/** {@link covenantInputFromUnifiedDiff} input — the diff text a producer wrote. */
|
|
20
|
+
export type CovenantInputFromUnifiedDiffSpec = {
|
|
21
|
+
/** The whole unified diff; zero bytes is the empty observation. */
|
|
22
|
+
text: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Translate a unified diff into the covenant input IR: one toolCall per file block in input
|
|
26
|
+
* order (a rename is two, the deletion first), and no `actor` key — a diff proves no author.
|
|
27
|
+
*/
|
|
28
|
+
export declare function covenantInputFromUnifiedDiff(spec: CovenantInputFromUnifiedDiffSpec): CovenantInput;
|