polydeukes 0.6.1 → 0.7.1
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 +14 -28
- package/README.md +14 -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 +103 -0
- package/dist/covenant/discipline.js +667 -0
- package/dist/covenant/dispatch.d.ts +153 -0
- package/dist/covenant/dispatch.js +237 -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 +72 -39
- package/dist/covenant-check.js +260 -121
- package/dist/diff-ir.d.ts +28 -0
- package/dist/diff-ir.js +310 -0
- package/dist/docs/README.ko.md +8 -8
- package/dist/docs/README.md +8 -8
- package/dist/docs/catalog.json +44 -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 +39 -34
- package/dist/docs/how-to/connect-surfaces.md +36 -33
- package/dist/docs/how-to/write-disciplines.ko.md +61 -6
- package/dist/docs/how-to/write-disciplines.md +62 -6
- package/dist/docs/index.json +607 -373
- 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 +14 -9
- package/dist/docs/reference/cli/explain.md +15 -10
- package/dist/docs/reference/cli/init.ko.md +52 -52
- package/dist/docs/reference/cli/init.md +43 -55
- package/dist/docs/reference/configuration/index.ko.md +171 -66
- package/dist/docs/reference/configuration/index.md +178 -71
- package/dist/docs/reference/packages/adapter-claude-code.ko.md +38 -11
- package/dist/docs/reference/packages/adapter-claude-code.md +38 -14
- package/dist/docs/reference/packages/adapter-grok.ko.md +77 -0
- package/dist/docs/reference/packages/adapter-grok.md +79 -0
- package/dist/docs/reference/packages/core.ko.md +49 -3
- package/dist/docs/reference/packages/core.md +54 -5
- package/dist/docs/reference/packages/polydeukes.ko.md +138 -77
- package/dist/docs/reference/packages/polydeukes.md +146 -81
- package/dist/docs/reference/packages/sdk-ts.ko.md +161 -0
- package/dist/docs/reference/packages/sdk-ts.md +164 -0
- package/dist/docs/troubleshooting.ko.md +17 -15
- package/dist/docs/troubleshooting.md +18 -14
- package/dist/docs/tutorials/first-judgment.ko.md +2 -2
- package/dist/docs/tutorials/first-judgment.md +2 -2
- package/dist/explain.d.ts +1 -1
- package/dist/explain.js +37 -22
- package/dist/load-config.d.ts +1 -1
- package/dist/load-config.js +1 -1
- 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 +9 -3
- 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 +3 -17
- 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,25 @@
|
|
|
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 { homedir } from 'node:os';
|
|
13
14
|
import { resolve } from 'node:path';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
15
|
+
import { appendRecordFailOpen, DEFAULT_TELEMETRY_LOG_PATH, isPlainObject, normalizeProtectedPaths, transcriptFromSession, } from '@polydeukes/core';
|
|
16
|
+
import { compareBaseline, updateBaseline } from './baseline.js';
|
|
17
|
+
import { covenantModule } from './covenant/module.js';
|
|
18
|
+
import { ttlWitness } from './covenant/ttl-witness.js';
|
|
19
|
+
import { STAGED_DELETE, STAGED_WRITE } from './diff-ir.js';
|
|
17
20
|
import { loadConfig } from './load-config.js';
|
|
18
|
-
import { unobservedPreStateReader } from './pre-state-reader.js';
|
|
19
|
-
|
|
20
|
-
* The TTY witness predicate, or undefined when no valve can exist (no witness configured
|
|
21
|
-
* or no TTY seam). It fires on the first registration that broke, names it from the
|
|
22
|
-
* dispatcher's context, and caches the answer: one commit, at most one prompt, full-token
|
|
23
|
-
* equality. Both sides are trimmed like the session valve, since config validation accepts
|
|
24
|
-
* a padded token. The cache latches closed before the seam is consulted so a throwing seam
|
|
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
|
-
}
|
|
21
|
+
import { sessionPreStateReader, unobservedPreStateReader } from './pre-state-reader.js';
|
|
22
|
+
import { worktreeReader } from './worktree-reader.js';
|
|
44
23
|
/**
|
|
45
24
|
* One blocked record for a run that failed closed before any dispatch could judge.
|
|
46
25
|
* `appendRecordFailOpen` creates the missing `.polydeukes/` of a never-judged repository,
|
|
@@ -57,38 +36,85 @@ function recordFailClosed(telemetryPath) {
|
|
|
57
36
|
});
|
|
58
37
|
}
|
|
59
38
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
39
|
+
* This runner's registration set — one assembly that the runner dispatches and `explain`
|
|
40
|
+
* renders.
|
|
41
|
+
*
|
|
42
|
+
* The surface picks the discipline lists; the two IR keys shape the meta-covenants and
|
|
43
|
+
* nothing else — the roster says which names route to which meta-covenant, and the session
|
|
44
|
+
* says whether there is history to protect, to bind, and to read pre-state from disk for.
|
|
62
45
|
*/
|
|
63
|
-
export function
|
|
64
|
-
const { config, rootDir, covenant, witness } = spec;
|
|
65
|
-
const { protectedPaths:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
46
|
+
export function assembleCheckRegistrations(spec) {
|
|
47
|
+
const { config, rootDir, covenant, surface, tools, session, transcript, witness } = spec;
|
|
48
|
+
const protectedPaths = normalizeProtectedPaths({ protectedPaths: config.protectedPaths ?? [] });
|
|
49
|
+
// The shared list first, so both surfaces read the same prefix in the rows and in
|
|
50
|
+
// `pdks explain`.
|
|
51
|
+
const surfaceDisciplines = surface === 'session' ? config.sessionDisciplines : config.changeSetDisciplines;
|
|
52
|
+
const disciplines = [...(config.disciplines ?? []), ...(surfaceDisciplines ?? [])];
|
|
53
|
+
const shellTools = tools?.shell ?? [];
|
|
54
|
+
const commandArgs = tools?.commandArgs ?? [];
|
|
55
|
+
const evidencePath = session?.evidencePath;
|
|
56
|
+
// An EMPTY roster is a host that declared it has no mutating tool, so only an ABSENT
|
|
57
|
+
// `tools` falls back to the staged names — a length check would route names no host call
|
|
58
|
+
// carries. The declared emptiness leaves no tool axis to register, the same way an empty
|
|
59
|
+
// shell roster leaves no shell axis.
|
|
60
|
+
const mutatingToolNames = tools?.mutating ?? [STAGED_WRITE, STAGED_DELETE];
|
|
74
61
|
const registrations = [
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
62
|
+
...(mutatingToolNames.length === 0
|
|
63
|
+
? []
|
|
64
|
+
: [covenant.selfModRegistration({ protectedPaths, mutatingToolNames, witness })]),
|
|
65
|
+
// No shell tool is no shell axis, not a shell axis over nothing: registering it with an
|
|
66
|
+
// empty roster would judge a command line the host said it has no tool for.
|
|
67
|
+
...(shellTools.length === 0
|
|
68
|
+
? []
|
|
69
|
+
: [covenant.shellModRegistration({ protectedPaths, shellTools, commandArgs, witness })]),
|
|
70
|
+
// The evidence file is what the witness and every history declaration read, so a call
|
|
71
|
+
// rewriting it could forge its own permission. Nothing to protect where the host named
|
|
72
|
+
// no evidence path.
|
|
73
|
+
...(evidencePath === undefined
|
|
74
|
+
? []
|
|
75
|
+
: [
|
|
76
|
+
covenant.transcriptModRegistration({
|
|
77
|
+
transcriptPath: evidencePath,
|
|
78
|
+
// The env value first, since that is what the judged shell expands `~` and
|
|
79
|
+
// `$HOME` from; `homedir()` reads the same passwd entry bash falls back to when
|
|
80
|
+
// HOME is unset.
|
|
81
|
+
home: process.env.HOME ?? homedir(),
|
|
82
|
+
shellTools,
|
|
83
|
+
commandArgs,
|
|
84
|
+
mutatingTools: mutatingToolNames,
|
|
85
|
+
witness,
|
|
86
|
+
}),
|
|
87
|
+
]),
|
|
80
88
|
...covenant.compileDisciplineRegistrations({
|
|
81
89
|
disciplines,
|
|
82
90
|
rootDir,
|
|
83
|
-
shellTools
|
|
84
|
-
commandArgs
|
|
85
|
-
|
|
86
|
-
|
|
91
|
+
shellTools,
|
|
92
|
+
commandArgs,
|
|
93
|
+
// A session call is judged before its tool runs, so the working tree IS the pre-state;
|
|
94
|
+
// without one the input carries the pre its own observation saw.
|
|
95
|
+
readPreState: session === undefined ? unobservedPreStateReader : sessionPreStateReader,
|
|
96
|
+
// That reader answers nothing, so the surface has no pre-state channel to complete a
|
|
97
|
+
// shell write's evidence with. Saying so keeps the absence an environment fact: a
|
|
98
|
+
// reader that answers `undefined` per location means that location failed, which
|
|
99
|
+
// blocks, and a shell call would then decide entries that never read its evidence.
|
|
100
|
+
observesPreState: session !== undefined,
|
|
101
|
+
// A diff-translated change carries the hunk's added lines as `post`, never the whole
|
|
102
|
+
// file, so a `file` binding on a staged path reads the tree — the state the change
|
|
103
|
+
// set left. A session call's `post` is the whole text it is about to write.
|
|
104
|
+
postIsWholeFile: surface === 'session',
|
|
87
105
|
witness,
|
|
106
|
+
// The session itself, injected rather than its path: a declaration reading a
|
|
107
|
+
// `transcript` binding sees it flattened, and its absence is the absence of a session.
|
|
108
|
+
transcript,
|
|
88
109
|
}),
|
|
89
110
|
];
|
|
90
111
|
return registrations;
|
|
91
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* The name `explain` renders the change-set surface from — the same assembly, called with
|
|
115
|
+
* `surface: 'changeSet'` and neither IR key.
|
|
116
|
+
*/
|
|
117
|
+
export const assembleChangeSetRegistrations = assembleCheckRegistrations;
|
|
92
118
|
/** One stage's failure disposition: the stderr line, the recorded row, and exit 2. */
|
|
93
119
|
function failClosed(telemetryPath, error) {
|
|
94
120
|
process.stderr.write(`covenant check failed closed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
@@ -105,9 +131,16 @@ function failClosed(telemetryPath, error) {
|
|
|
105
131
|
function settleConfig(spec) {
|
|
106
132
|
let telemetryPath;
|
|
107
133
|
try {
|
|
108
|
-
|
|
134
|
+
// The environment variable sits between the caller's path and the config's, matching
|
|
135
|
+
// what the baseline comparison in this same process already resolves — the two write
|
|
136
|
+
// to one log, so they must agree on which one. It is how a test run collects its own
|
|
137
|
+
// rows without editing the config it is measuring.
|
|
138
|
+
const envPath = process.env.POLYDEUKES_TELEMETRY_PATH;
|
|
139
|
+
telemetryPath =
|
|
140
|
+
spec.telemetryPath ?? envPath ?? resolve(spec.repoRoot, DEFAULT_TELEMETRY_LOG_PATH);
|
|
109
141
|
const { config } = loadConfig({ rootDir: spec.repoRoot });
|
|
110
|
-
telemetryPath =
|
|
142
|
+
telemetryPath =
|
|
143
|
+
spec.telemetryPath ?? envPath ?? resolve(spec.repoRoot, config.telemetry.logPath);
|
|
111
144
|
return { settled: true, telemetryPath, config };
|
|
112
145
|
}
|
|
113
146
|
catch (error) {
|
|
@@ -115,93 +148,111 @@ function settleConfig(spec) {
|
|
|
115
148
|
}
|
|
116
149
|
}
|
|
117
150
|
/**
|
|
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.
|
|
151
|
+
* The observation's change set: the paths of the input's toolCalls that carry file-change
|
|
152
|
+
* evidence, in input order.
|
|
136
153
|
*
|
|
137
154
|
* 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
|
-
*
|
|
155
|
+
* changes. A deletion carries evidence and stays; a binary blob, which arrives as a call
|
|
156
|
+
* with no evidence, produces no world of its own — listing it would hand the change-set
|
|
157
|
+
* relations a path no world can ever answer for.
|
|
141
158
|
*/
|
|
142
|
-
function changedPaths(
|
|
159
|
+
function changedPaths(input) {
|
|
143
160
|
const paths = [];
|
|
144
|
-
for (const call of
|
|
161
|
+
for (const call of input.toolCalls) {
|
|
145
162
|
if (call.fileChange !== undefined)
|
|
146
163
|
paths.push(call.fileChange.path);
|
|
147
164
|
}
|
|
148
165
|
return paths;
|
|
149
166
|
}
|
|
150
167
|
/**
|
|
151
|
-
* Assemble the registrations and dispatch every
|
|
152
|
-
*
|
|
168
|
+
* Assemble the registrations and dispatch every toolCall. Any throw here (a
|
|
169
|
+
* registration-build failure) is unjudgeable: block and leave one record.
|
|
153
170
|
*/
|
|
154
|
-
async function
|
|
171
|
+
async function judgeInput(spec, telemetryPath, config, input) {
|
|
155
172
|
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);
|
|
173
|
+
// The umbrella's own judge module, so this runner runs the judges the session
|
|
174
|
+
// hook does; a test injects a module with one member replaced.
|
|
175
|
+
const covenant = spec.covenant ?? covenantModule;
|
|
168
176
|
let blocked = false;
|
|
169
177
|
let advisedCount = 0;
|
|
170
|
-
|
|
178
|
+
const { tools, session } = input;
|
|
179
|
+
// One witness predicate shared by every registration: a witness is a session-wide
|
|
180
|
+
// permission the human granted, not a per-covenant one. It exists only where a session
|
|
181
|
+
// does — the valve reads human utterances, and an input with no session has none to
|
|
182
|
+
// read — so a session-free input assembles exactly the registrations it did before.
|
|
183
|
+
const witness = config.witness === undefined || session === undefined
|
|
184
|
+
? undefined
|
|
185
|
+
: ttlWitness({
|
|
186
|
+
token: config.witness.token,
|
|
187
|
+
// Minutes are the human-facing unit in config; the predicate takes milliseconds.
|
|
188
|
+
ttlMs: config.witness.ttlMinutes * 60_000,
|
|
189
|
+
});
|
|
190
|
+
const transcript = session === undefined ? undefined : transcriptFromSession(session);
|
|
191
|
+
// Assembled ONCE for the run, not per call: a judge takes its call set as an argument,
|
|
171
192
|
// so the table is payload-free. Recompiling per file would repeat every compile-time
|
|
172
193
|
// side effect — the stderr line a config-faulted discipline names itself with would
|
|
173
194
|
// print once per staged file rather than once.
|
|
174
|
-
const registrations =
|
|
195
|
+
const registrations = assembleCheckRegistrations({
|
|
175
196
|
config,
|
|
176
197
|
rootDir: spec.repoRoot,
|
|
177
198
|
covenant,
|
|
199
|
+
surface: spec.surface,
|
|
200
|
+
tools,
|
|
201
|
+
session,
|
|
202
|
+
transcript,
|
|
178
203
|
witness,
|
|
179
204
|
});
|
|
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
|
|
205
|
+
// One plan and one supply for the run: the per-call loop shares them, so the tree is
|
|
206
|
+
// read once per named file rather than once per change. The change set carries the whole
|
|
207
|
+
// observation because this surface dispatches one call at a time to keep telemetry at
|
|
183
208
|
// one row per file — a set no judge could derive from the input it is handed.
|
|
184
209
|
const { files } = covenant.supplySources({
|
|
185
210
|
plan: covenant.planSources({ registrations }),
|
|
186
|
-
read:
|
|
211
|
+
read: worktreeReader({ repoRoot: spec.repoRoot }),
|
|
187
212
|
});
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
213
|
+
// The session's channels ride into the world here rather than through a reader: the
|
|
214
|
+
// host already observed them, and this root opens no file the input did not name. An
|
|
215
|
+
// input with no session carries no channels key at all — an absent channel is a
|
|
216
|
+
// different fact from a channel that observed nothing.
|
|
217
|
+
const channels = session?.channels;
|
|
218
|
+
const world = {
|
|
219
|
+
files,
|
|
220
|
+
changes: changedPaths(input),
|
|
221
|
+
...(channels === undefined ? {} : { channels }),
|
|
222
|
+
};
|
|
223
|
+
for (const call of input.toolCalls) {
|
|
191
224
|
const { exitCode, results } = await covenant.dispatchCovenants({
|
|
192
|
-
stdinPayload: JSON.stringify(
|
|
225
|
+
stdinPayload: JSON.stringify({
|
|
226
|
+
toolCalls: [call],
|
|
227
|
+
subagentSpawns: input.subagentSpawns,
|
|
228
|
+
userMessages: input.userMessages,
|
|
229
|
+
// Neither the roster nor the session travels with the call: the roster is spent at
|
|
230
|
+
// assembly (no judge body reads it), and the session is injected as the transcript,
|
|
231
|
+
// because a dispatch's input is one judged unit rather than the session it happened in.
|
|
232
|
+
...(input.actor === undefined ? {} : { actor: input.actor }),
|
|
233
|
+
}),
|
|
193
234
|
registrations,
|
|
194
235
|
telemetryPath,
|
|
195
236
|
dispatcherLabel: 'covenant-check',
|
|
196
|
-
enforce,
|
|
237
|
+
enforce: spec.enforce ?? 'advise',
|
|
238
|
+
transcript,
|
|
197
239
|
world,
|
|
198
240
|
});
|
|
199
241
|
if (exitCode === 2)
|
|
200
242
|
blocked = true;
|
|
201
243
|
advisedCount += results.filter((result) => result.event === 'advised').length;
|
|
244
|
+
// One call, one record: a call no registration routed leaves no row of its own, so
|
|
245
|
+
// the runner writes the pass under its label — the session surface does the same.
|
|
246
|
+
if (exitCode === 0 && results.length === 0) {
|
|
247
|
+
const subject = call.args?.file_path;
|
|
248
|
+
appendRecordFailOpen(telemetryPath, {
|
|
249
|
+
event: 'passed',
|
|
250
|
+
label: 'covenant-check',
|
|
251
|
+
subject: typeof subject === 'string' ? subject : '-',
|
|
252
|
+
});
|
|
253
|
+
}
|
|
202
254
|
}
|
|
203
|
-
// Names no level:
|
|
204
|
-
// fate is read from the run.
|
|
255
|
+
// Names no level: the commit's fate is read from the run.
|
|
205
256
|
if (advisedCount > 0) {
|
|
206
257
|
const outcome = blocked ? 'commit blocked by another verdict' : 'commit allowed';
|
|
207
258
|
process.stderr.write(`covenant advisory: ${advisedCount} verdict(s) recorded as advised, ${outcome}\n`);
|
|
@@ -213,24 +264,112 @@ async function judgeChanges(spec, domain, telemetryPath, config, changes) {
|
|
|
213
264
|
}
|
|
214
265
|
}
|
|
215
266
|
/**
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
267
|
+
* Reject an input whose roster or session evidence this runner cannot judge.
|
|
268
|
+
*
|
|
269
|
+
* A shape it cannot read is a block, never a default: folding a missing collection into
|
|
270
|
+
* `[]` or a bare string into a one-element list would judge an input nobody wrote. A key
|
|
271
|
+
* whose value is `undefined` reads as absent — a JSON round-trip drops it, so only an
|
|
272
|
+
* in-process caller can even write that shape.
|
|
273
|
+
*/
|
|
274
|
+
function assertJudgeableShape(input) {
|
|
275
|
+
const { tools, session } = input;
|
|
276
|
+
if (tools !== undefined) {
|
|
277
|
+
if (!isPlainObject(tools))
|
|
278
|
+
throw new Error('input carries a tools key that is not an object');
|
|
279
|
+
for (const key of ['mutating', 'shell', 'commandArgs']) {
|
|
280
|
+
const names = tools[key];
|
|
281
|
+
if (!Array.isArray(names))
|
|
282
|
+
throw new Error(`input carries no tools.${key} array`);
|
|
283
|
+
// A roster names tools; a non-string entry is a name no call could ever carry, and
|
|
284
|
+
// routing on it would silently leave that slot of the roster unjudged.
|
|
285
|
+
if (!names.every((name) => typeof name === 'string')) {
|
|
286
|
+
throw new Error(`input carries a non-string name in tools.${key}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// A shell tool with no argument key is a roster the shell judge cannot read: every
|
|
290
|
+
// routed call would land unjudgeable, which no posture softens. Refuse the run instead.
|
|
291
|
+
if (tools.shell.length > 0 && tools.commandArgs.length === 0) {
|
|
292
|
+
throw new Error('input carries tools.shell without a tools.commandArgs key to read');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (session === undefined)
|
|
296
|
+
return;
|
|
297
|
+
if (!isPlainObject(session)) {
|
|
298
|
+
throw new Error('input carries a session key that is not an object');
|
|
299
|
+
}
|
|
300
|
+
for (const key of ['userMessages', 'toolCalls']) {
|
|
301
|
+
if (!Array.isArray(session[key]))
|
|
302
|
+
throw new Error(`input carries no session.${key} array`);
|
|
303
|
+
}
|
|
304
|
+
if (session.evidencePath !== undefined && typeof session.evidencePath !== 'string') {
|
|
305
|
+
throw new Error('input carries a session.evidencePath that is not a string');
|
|
306
|
+
}
|
|
307
|
+
if (session.channels !== undefined && !isPlainObject(session.channels)) {
|
|
308
|
+
throw new Error('input carries a session.channels that is not an object');
|
|
309
|
+
}
|
|
310
|
+
const sidecar = session.channels?.sidecar;
|
|
311
|
+
if (sidecar !== undefined && typeof sidecar !== 'string') {
|
|
312
|
+
throw new Error('input carries a session.channels.sidecar that is not text');
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Judge one observation of `repoRoot` exactly as the session surface would, from the IR the
|
|
317
|
+
* caller hands in. Async because the dispatcher spawns covenant bodies. An input with no
|
|
318
|
+
* toolCalls is an explicit pass: nothing to judge, no records.
|
|
319
|
+
*
|
|
320
|
+
* An input carrying a session is one call of a live agent session, so the post-hoc state
|
|
321
|
+
* comparison wraps the judgment: it runs first, reading the window the previous call left,
|
|
322
|
+
* and the re-establishment runs last, folding this call's own judged writes in. Both sides
|
|
323
|
+
* sit OUTSIDE the judgment with their own catch — a mechanism whose purpose is to record
|
|
324
|
+
* rather than stop may never reach an exit code.
|
|
219
325
|
*/
|
|
220
326
|
export async function runCovenantCheck(spec) {
|
|
221
327
|
const settlement = settleConfig(spec);
|
|
222
328
|
if (!settlement.settled)
|
|
223
329
|
return { exitCode: settlement.exitCode };
|
|
224
330
|
const { telemetryPath, config } = settlement;
|
|
225
|
-
|
|
226
|
-
let changes;
|
|
331
|
+
let input;
|
|
227
332
|
try {
|
|
228
|
-
|
|
333
|
+
input = typeof spec.input === 'function' ? spec.input() : spec.input;
|
|
334
|
+
// The world axis is this root's to fill. An input that supplies its own would let a
|
|
335
|
+
// caller choose the files the judge reads.
|
|
336
|
+
if ('world' in input) {
|
|
337
|
+
throw new Error('input carries a world key: the world axis is the runner’s');
|
|
338
|
+
}
|
|
339
|
+
if (!Array.isArray(input.toolCalls))
|
|
340
|
+
throw new Error('input carries no toolCalls array');
|
|
341
|
+
assertJudgeableShape(input);
|
|
229
342
|
}
|
|
230
343
|
catch (error) {
|
|
231
344
|
return failClosed(telemetryPath, error);
|
|
232
345
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
346
|
+
// The comparison reads and writes the log the judgment writes — the settled path, never
|
|
347
|
+
// a second resolution — and observes the protected entries the settled config names.
|
|
348
|
+
// A session call with nothing judgeable still compares and re-establishes: the window it
|
|
349
|
+
// opens is what the next call reads, and a skipped call would leave it stale.
|
|
350
|
+
let comparison;
|
|
351
|
+
if (input.session !== undefined) {
|
|
352
|
+
try {
|
|
353
|
+
comparison = {
|
|
354
|
+
repoRoot: spec.repoRoot,
|
|
355
|
+
telemetryPath,
|
|
356
|
+
entries: normalizeProtectedPaths({ protectedPaths: config.protectedPaths }),
|
|
357
|
+
};
|
|
358
|
+
compareBaseline(comparison);
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
// fail-open: a comparison that could not run leaves the judgment exactly as it was.
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const result = input.toolCalls.length === 0
|
|
365
|
+
? { exitCode: 0 }
|
|
366
|
+
: await judgeInput(spec, telemetryPath, config, input);
|
|
367
|
+
try {
|
|
368
|
+
if (comparison !== undefined)
|
|
369
|
+
updateBaseline(comparison);
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
// fail-open: an unwritable baseline costs the next call's detection, never this verdict.
|
|
373
|
+
}
|
|
374
|
+
return result;
|
|
236
375
|
}
|
|
@@ -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;
|