polydeukes 0.6.0 → 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 +30 -70
- package/README.md +31 -84
- package/dist/baseline.d.ts +37 -1
- package/dist/baseline.js +68 -1
- package/dist/bin.d.ts +3 -4
- package/dist/bin.js +79 -93
- 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 +60 -0
- package/dist/docs/README.md +64 -0
- package/dist/docs/catalog.json +480 -0
- package/dist/docs/concepts/judgment.ko.md +113 -0
- package/dist/docs/concepts/judgment.md +113 -0
- package/dist/docs/how-to/configure-project.ko.md +99 -0
- package/dist/docs/how-to/configure-project.md +98 -0
- package/dist/docs/how-to/connect-surfaces.ko.md +120 -0
- package/dist/docs/how-to/connect-surfaces.md +121 -0
- package/dist/docs/how-to/write-disciplines.ko.md +179 -0
- package/dist/docs/how-to/write-disciplines.md +181 -0
- package/dist/docs/index.json +2280 -0
- package/dist/docs/reference/cli/covenant-check.ko.md +148 -0
- package/dist/docs/reference/cli/covenant-check.md +153 -0
- package/dist/docs/reference/cli/docs.ko.md +97 -0
- package/dist/docs/reference/cli/docs.md +95 -0
- package/dist/docs/reference/cli/explain.ko.md +84 -0
- package/dist/docs/reference/cli/explain.md +89 -0
- package/dist/docs/reference/cli/init.ko.md +119 -0
- package/dist/docs/reference/cli/init.md +119 -0
- package/dist/docs/reference/configuration/index.ko.md +553 -0
- package/dist/docs/reference/{configuration.md → configuration/index.md} +215 -90
- package/dist/docs/reference/packages/adapter-claude-code.ko.md +110 -0
- package/dist/docs/reference/{adapter-claude-code.md → packages/adapter-claude-code.md} +47 -19
- 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 +174 -0
- package/dist/docs/reference/{core.md → packages/core.md} +74 -12
- package/dist/docs/reference/packages/polydeukes.ko.md +195 -0
- package/dist/docs/reference/packages/polydeukes.md +204 -0
- 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 +144 -0
- package/dist/docs/troubleshooting.md +103 -151
- package/dist/docs/tutorials/first-judgment.ko.md +82 -0
- package/dist/docs/tutorials/first-judgment.md +81 -0
- package/dist/docs-catalog.d.ts +25 -0
- package/dist/docs-catalog.js +450 -0
- package/dist/docs-library.d.ts +23 -0
- package/dist/docs-library.js +347 -0
- package/dist/docs-markdown.d.ts +32 -0
- package/dist/docs-markdown.js +150 -0
- package/dist/docs-query.d.ts +11 -40
- package/dist/docs-query.js +28 -122
- package/dist/docs-types.d.ts +105 -0
- package/dist/docs-types.js +2 -0
- 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/configuration.md +0 -103
- package/dist/docs/installation.md +0 -241
- package/dist/docs/reference/adapter-git.md +0 -100
- package/dist/docs/reference/covenant.md +0 -116
- package/dist/docs/reference/polydeukes.md +0 -315
- 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 -469
- package/dist/init-grok.d.ts +0 -51
- package/dist/init-grok.js +0 -242
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discipline registration compiler.
|
|
3
|
+
*
|
|
4
|
+
* `compileDisciplineRegistrations` turns validated entries into dispatcher registrations —
|
|
5
|
+
* one per entry, routed by the declaration's own scope (`matches`), judged by an in-process
|
|
6
|
+
* thunk that runs the compiled declaration over each world the input yields. Absolute paths
|
|
7
|
+
* are relativized against the repo root first, so a path outside the root never routes: a
|
|
8
|
+
* declaration's scope is written repo-relative.
|
|
9
|
+
*/
|
|
10
|
+
import { isAbsolute, posix, relative, resolve } from 'node:path';
|
|
11
|
+
import { allFileChanges, } from '@polydeukes/core';
|
|
12
|
+
import { executedText } from './bash-line.js';
|
|
13
|
+
import { compileDeclaration, judgeDeclaration, scopeAdmits, witnessOpens, } from './declaration-engine.js';
|
|
14
|
+
import { UNJUDGEABLE_OUTCOME } from './run-covenant.js';
|
|
15
|
+
import { deriveShellChanges } from './shell-evidence.js';
|
|
16
|
+
/**
|
|
17
|
+
* Relativize a file-change path against the root for glob matching. A relative path passes
|
|
18
|
+
* through; an absolute path outside `rootDir` yields null (never matches — discipline scope
|
|
19
|
+
* is declared repo-relative).
|
|
20
|
+
*/
|
|
21
|
+
function relativizeForScope(filePath, rootDir) {
|
|
22
|
+
if (!isAbsolute(filePath)) {
|
|
23
|
+
// A relative spelling normalizes before matching — `./x` and `a/../x` name x, and a
|
|
24
|
+
// spelling that resolves out of the root matches nothing. Matching verbatim instead
|
|
25
|
+
// lets an equivalent spelling escape every scope.
|
|
26
|
+
const normalized = posix.normalize(filePath);
|
|
27
|
+
if (normalized === '.' || normalized.startsWith('..'))
|
|
28
|
+
return null;
|
|
29
|
+
return normalized;
|
|
30
|
+
}
|
|
31
|
+
const relativized = relative(rootDir, filePath);
|
|
32
|
+
if (relativized.startsWith('..') || isAbsolute(relativized))
|
|
33
|
+
return null;
|
|
34
|
+
return relativized;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Turn each observation of the input into one world, in input order.
|
|
38
|
+
*
|
|
39
|
+
* The seven source names are fixed: `target.path`, `pre`, `post`, the paired `state`,
|
|
40
|
+
* `changes`, `command`, and `actor`. A side the change does not carry is an ABSENT key, never a
|
|
41
|
+
* fabricated default — what a missing source means is the declaration's own `supply` policy
|
|
42
|
+
* to state. `state` exists only where both sides do, so a declaration comparing before with
|
|
43
|
+
* after refuses a change that has no before. A path outside the root is dropped, as
|
|
44
|
+
* everywhere else in this module: a declaration's scope is written repo-relative.
|
|
45
|
+
*
|
|
46
|
+
* `changes` is the observation unit's change set, the same array instance in every world so
|
|
47
|
+
* a large commit costs one list rather than one per change. It is derived from this input
|
|
48
|
+
* unless the host supplied its own — a host whose observation is wider than the changes it
|
|
49
|
+
* dispatches at once has a set no derivation here could reach.
|
|
50
|
+
*
|
|
51
|
+
* `actor` is the input's actor object, carried by every world of the input — the file
|
|
52
|
+
* worlds and the call world alike. An input without one leaves the key absent, since `{}`
|
|
53
|
+
* is the positive value saying the host observed an actor that is not a subagent.
|
|
54
|
+
*
|
|
55
|
+
* `command` is the first shell call's command line with its heredoc bodies and herestring
|
|
56
|
+
* words deleted, carried by every world of the input. bash hands those bytes to the command
|
|
57
|
+
* as stdin data instead of executing them — except a body or word bash expands first, which
|
|
58
|
+
* stays — and whether they become a file write is the shell-evidence path's judgment, which
|
|
59
|
+
* reads the command as written.
|
|
60
|
+
* A shell call changing no in-scope file is still one observation, so it yields the single
|
|
61
|
+
* CALL WORLD — subject `'-'`, no `target.path`, so a path-scoped declaration finds no string
|
|
62
|
+
* and refuses it. A surface whose shell tools are empty observes no shell call, hence no
|
|
63
|
+
* `command` and no call world.
|
|
64
|
+
*/
|
|
65
|
+
export function worldsFromInput(spec) {
|
|
66
|
+
const { input, rootDir } = spec;
|
|
67
|
+
const rawCommand = filterShellCommands(input.toolCalls, spec.shellTools, spec.commandArgs)[0];
|
|
68
|
+
const command = rawCommand === undefined ? undefined : executedText(rawCommand);
|
|
69
|
+
const scoped = [];
|
|
70
|
+
for (const change of allFileChanges(input)) {
|
|
71
|
+
const path = relativizeForScope(change.path, rootDir);
|
|
72
|
+
if (path !== null)
|
|
73
|
+
scoped.push({ path, change });
|
|
74
|
+
}
|
|
75
|
+
const changes = input.world?.changes ?? scoped.map((entry) => entry.path);
|
|
76
|
+
// The facts every world of this input shares. A key is set only when its value exists: a
|
|
77
|
+
// key holding `undefined` would satisfy the engine's presence check and skip the supply
|
|
78
|
+
// policy, so the step would run over nothing and could answer pass.
|
|
79
|
+
const shared = {
|
|
80
|
+
changes,
|
|
81
|
+
...(command !== undefined && { command }),
|
|
82
|
+
...(input.actor !== undefined && { actor: input.actor }),
|
|
83
|
+
};
|
|
84
|
+
const worlds = [];
|
|
85
|
+
for (const { path, change } of scoped) {
|
|
86
|
+
const world = { 'target.path': path, ...shared };
|
|
87
|
+
// A kind this host does not know (a stale adapter dist) yields no world at all: a key
|
|
88
|
+
// holding `undefined` would satisfy the engine's presence check and skip the supply
|
|
89
|
+
// policy, so the step would run over nothing and could answer pass.
|
|
90
|
+
switch (change.kind) {
|
|
91
|
+
case 'create':
|
|
92
|
+
world.post = change.post;
|
|
93
|
+
break;
|
|
94
|
+
case 'modify':
|
|
95
|
+
world.pre = change.pre;
|
|
96
|
+
world.post = change.post;
|
|
97
|
+
world.state = { pre: change.pre, post: change.post };
|
|
98
|
+
break;
|
|
99
|
+
case 'delete':
|
|
100
|
+
if (change.pre !== undefined)
|
|
101
|
+
world.pre = change.pre;
|
|
102
|
+
break;
|
|
103
|
+
default:
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
worlds.push({ path, world });
|
|
107
|
+
}
|
|
108
|
+
if (worlds.length === 0 && command !== undefined)
|
|
109
|
+
return [{ path: '-', world: shared }];
|
|
110
|
+
return worlds;
|
|
111
|
+
}
|
|
112
|
+
/** The one shell-surface filter: named args of shell-tool calls, whatever the source. */
|
|
113
|
+
function filterShellCalls(calls, shellTools, commandArgs) {
|
|
114
|
+
const found = [];
|
|
115
|
+
for (const call of calls) {
|
|
116
|
+
if (!shellTools.includes(call.name))
|
|
117
|
+
continue;
|
|
118
|
+
for (const argName of commandArgs) {
|
|
119
|
+
const value = call.args?.[argName];
|
|
120
|
+
if (typeof value === 'string')
|
|
121
|
+
found.push({ toolName: call.name, command: value });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return found;
|
|
125
|
+
}
|
|
126
|
+
/** The command strings of shell-tool calls, whatever the source. */
|
|
127
|
+
function filterShellCommands(calls, shellTools, commandArgs) {
|
|
128
|
+
return filterShellCalls(calls, shellTools, commandArgs).map((call) => call.command);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Derive the shell-delivered signals of an input — the one derivation seam both the routing
|
|
132
|
+
* closures and the judged body consume, so the two can never disagree on what a command
|
|
133
|
+
* proves. Pure: completing the evidence with a pre-state is the body's job, since routing
|
|
134
|
+
* may not consult the reader.
|
|
135
|
+
*/
|
|
136
|
+
function deriveShellSignals(input, opts) {
|
|
137
|
+
const signals = { evidence: [], unjudgeable: [] };
|
|
138
|
+
for (const call of filterShellCalls(input.toolCalls, opts.shellTools, opts.commandArgs)) {
|
|
139
|
+
const derived = deriveShellChanges(call.command);
|
|
140
|
+
for (const change of derived.evidence) {
|
|
141
|
+
signals.evidence.push({ toolName: call.toolName, change });
|
|
142
|
+
}
|
|
143
|
+
signals.unjudgeable.push(...derived.unjudgeable);
|
|
144
|
+
}
|
|
145
|
+
return signals;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Complete shell-derived evidence with the surface's pre-state and attach it to the input.
|
|
149
|
+
* The hook runs before the tool does, so what the surface observes now IS the pre-state.
|
|
150
|
+
*
|
|
151
|
+
* Two rules. **One evidence, one call element**: `toolCall.fileChange` is singular, so each
|
|
152
|
+
* derived change rides its own element (same tool name, no args) rather than the shell call
|
|
153
|
+
* it came from. **Same-path evidence chains in command order**: only the first write consults
|
|
154
|
+
* the reader, and every later one composes onto its predecessor's post, or a truncate
|
|
155
|
+
* followed by a re-add would be forgiven as pre-existing debt.
|
|
156
|
+
*/
|
|
157
|
+
function enrichWithShellEvidence(input, opts) {
|
|
158
|
+
const derived = deriveShellSignals(input, opts);
|
|
159
|
+
if (derived.evidence.length === 0)
|
|
160
|
+
return input;
|
|
161
|
+
const composed = new Map();
|
|
162
|
+
const proven = [];
|
|
163
|
+
for (const { toolName, change } of derived.evidence) {
|
|
164
|
+
const location = resolve(opts.rootDir, change.path);
|
|
165
|
+
const chained = composed.get(location);
|
|
166
|
+
const pre = chained !== undefined ? chained : opts.readPreState(location);
|
|
167
|
+
if (pre === undefined) {
|
|
168
|
+
// Cannot judge means block: the thunk-level catch turns this throw into the
|
|
169
|
+
// undecidable-structure outcome — never a quiet uphold recorded as `passed`.
|
|
170
|
+
throw new Error(`pre-state of ${change.path} is unreadable`);
|
|
171
|
+
}
|
|
172
|
+
const post = change.mode === 'append' ? `${pre ?? ''}${change.content}` : change.content;
|
|
173
|
+
composed.set(location, post);
|
|
174
|
+
proven.push({
|
|
175
|
+
name: toolName,
|
|
176
|
+
fileChange: pre === null
|
|
177
|
+
? { kind: 'create', path: change.path, post }
|
|
178
|
+
: { kind: 'modify', path: change.path, pre, post },
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return { ...input, toolCalls: [...input.toolCalls, ...proven] };
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The paths a command writes in a way this layer cannot compute. The skip arm owns them: a
|
|
185
|
+
* computable write to the same path in the same command composes onto a state the
|
|
186
|
+
* uncomputable one then changes, so no world the body could build is the one the call leaves
|
|
187
|
+
* on disk.
|
|
188
|
+
*/
|
|
189
|
+
function uncomputablePaths(signals, rootDir) {
|
|
190
|
+
const owned = new Set();
|
|
191
|
+
for (const signal of signals.unjudgeable) {
|
|
192
|
+
const path = signal.path === undefined ? null : relativizeForScope(signal.path, rootDir);
|
|
193
|
+
if (path !== null)
|
|
194
|
+
owned.add(path);
|
|
195
|
+
}
|
|
196
|
+
return owned;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The first computable shell write a declaration's scope admits, judged on what the command
|
|
200
|
+
* text alone decides: the target path. A scope over any other source needs the world only
|
|
201
|
+
* the body can build (an append's content composes onto a pre-state the reader holds), so
|
|
202
|
+
* such a write is admitted here and the body settles it — routing to nothing would leave a
|
|
203
|
+
* write the body could break on with no row at all.
|
|
204
|
+
*/
|
|
205
|
+
function firstAdmittedShellWrite(compiled, input, opts, rootDir) {
|
|
206
|
+
const signals = deriveShellSignals(input, opts);
|
|
207
|
+
const owned = uncomputablePaths(signals, rootDir);
|
|
208
|
+
const testable = compiled.scope === undefined || compiled.scope.source === 'target.path';
|
|
209
|
+
for (const derived of signals.evidence) {
|
|
210
|
+
const path = relativizeForScope(derived.change.path, rootDir);
|
|
211
|
+
if (path === null || owned.has(path))
|
|
212
|
+
continue;
|
|
213
|
+
if (!testable || scopeAdmits(compiled, { 'target.path': path }))
|
|
214
|
+
return path;
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Append an entry's rationale to a break reason.
|
|
220
|
+
*
|
|
221
|
+
* The reason is one line an agent reads off stderr, so a `why` spanning several lines — a YAML
|
|
222
|
+
* block scalar writes exactly that — folds to spaces before it is appended. Every line break
|
|
223
|
+
* folds, CR included and not only the CRLF pair: a lone CR reaching a terminal returns the
|
|
224
|
+
* cursor to column zero, so it would repaint the rationale over the discipline id and path this
|
|
225
|
+
* reason has already named. A run of breaks folds to one space, which is what a block scalar's
|
|
226
|
+
* blank line and trailing newline produce. Emptiness is decided AFTER folding: a why of only
|
|
227
|
+
* breaks or spaces carries no rationale, and appending the separator alone would leave a
|
|
228
|
+
* dangling ` — why: `. Nothing about a verdict is decided here; the caller has already judged.
|
|
229
|
+
*/
|
|
230
|
+
function withWhy(reason, why) {
|
|
231
|
+
const folded = why?.replace(/[\r\n]+/g, ' ').trim();
|
|
232
|
+
return folded === undefined || folded === '' ? reason : `${reason} — why: ${folded}`;
|
|
233
|
+
}
|
|
234
|
+
/** The declaration compiler's fault value, distinguished from a compiled declaration. */
|
|
235
|
+
function isFault(value) {
|
|
236
|
+
return value.kind === 'config-fault';
|
|
237
|
+
}
|
|
238
|
+
/** Compile a declare entry's block, the entry's id supplying the declaration's name. */
|
|
239
|
+
function compileEntryDeclaration(entry) {
|
|
240
|
+
return compileDeclaration({
|
|
241
|
+
declaration: {
|
|
242
|
+
discipline: entry.id,
|
|
243
|
+
...entry.declare,
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* The first of `paths` the declaration's scope admits, judged as a world of that path alone.
|
|
249
|
+
*
|
|
250
|
+
* A shell line delivers a path and nothing else, so only a scope over `target.path` can be
|
|
251
|
+
* tested here. A scope over any other source admits every path: the write may be in scope
|
|
252
|
+
* and this layer cannot tell, which is exactly what the skip row is for.
|
|
253
|
+
*/
|
|
254
|
+
function firstAdmittedPath(compiled, paths, rootDir) {
|
|
255
|
+
const testable = compiled.scope === undefined || compiled.scope.source === 'target.path';
|
|
256
|
+
for (const path of paths) {
|
|
257
|
+
const scoped = relativizeForScope(path, rootDir);
|
|
258
|
+
if (scoped === null)
|
|
259
|
+
continue;
|
|
260
|
+
if (!testable || scopeAdmits(compiled, { 'target.path': scoped }))
|
|
261
|
+
return scoped;
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Whether a surface completes a computable shell write into a file world at all. Without a
|
|
267
|
+
* pre-state channel the derivation stops at the command text, so such a write is as
|
|
268
|
+
* uncomputable here as one this layer's table refuses.
|
|
269
|
+
*/
|
|
270
|
+
function completesShellWrites(spec) {
|
|
271
|
+
return spec.observesPreState !== false;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Whether an entry's shell-delivered writes are attributable to it — a declaration that
|
|
275
|
+
* compiles has a scope to attribute them by, and one that does not compile defines no match.
|
|
276
|
+
*/
|
|
277
|
+
function hasShellSkipArm(entry, spec) {
|
|
278
|
+
// No shell surface, no shell writes to detect: the arm's own matches predicate could
|
|
279
|
+
// never fire, so registering it would only misreport the entry as unjudgeable there.
|
|
280
|
+
if (spec.shellTools.length === 0 || spec.commandArgs.length === 0)
|
|
281
|
+
return false;
|
|
282
|
+
const compiled = compileEntryDeclaration(entry);
|
|
283
|
+
if (isFault(compiled))
|
|
284
|
+
return false;
|
|
285
|
+
// A declaration scoped on the command line owns no path: the shell call it judges is the
|
|
286
|
+
// world its body already saw, so an uncomputable write in the same call is not its row.
|
|
287
|
+
return compiled.scope?.source !== 'command';
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* The per-entry skip registration: a detected write in this entry's scope whose result
|
|
291
|
+
* cannot be computed records one `skipped` under the entry's own label, keeping the gain
|
|
292
|
+
* aggregation in one group instead of falling to the common backstop.
|
|
293
|
+
*
|
|
294
|
+
* On a surface with no pre-state channel the computable writes join them, because there the
|
|
295
|
+
* judging arm receives no world for such a write either: routing it there would record the
|
|
296
|
+
* entry as having judged a file it never read.
|
|
297
|
+
*/
|
|
298
|
+
function shellSkipArm(entry, spec) {
|
|
299
|
+
const opts = {
|
|
300
|
+
rootDir: spec.rootDir,
|
|
301
|
+
shellTools: spec.shellTools,
|
|
302
|
+
commandArgs: spec.commandArgs,
|
|
303
|
+
readPreState: spec.readPreState,
|
|
304
|
+
};
|
|
305
|
+
// Where the surface completes them, this arm carries the UNCOMPUTABLE writes only — a
|
|
306
|
+
// computable one becomes a file change the judging arm sees, so admitting it here would
|
|
307
|
+
// leave one call two rows.
|
|
308
|
+
const completes = completesShellWrites(spec);
|
|
309
|
+
const compiled = compileEntryDeclaration(entry);
|
|
310
|
+
const unjudgeable = (input) => {
|
|
311
|
+
const signals = deriveShellSignals(input, opts);
|
|
312
|
+
const paths = signals.unjudgeable.flatMap((signal) => signal.path ?? []);
|
|
313
|
+
if (completes)
|
|
314
|
+
return paths;
|
|
315
|
+
return [...paths, ...signals.evidence.map((derived) => derived.change.path)];
|
|
316
|
+
};
|
|
317
|
+
const scoped = isFault(compiled)
|
|
318
|
+
? () => null
|
|
319
|
+
: (input) => firstAdmittedPath(compiled, unjudgeable(input), spec.rootDir);
|
|
320
|
+
return {
|
|
321
|
+
label: entry.id,
|
|
322
|
+
protectedPaths: [],
|
|
323
|
+
matches: scoped,
|
|
324
|
+
skip: {
|
|
325
|
+
reason: completes
|
|
326
|
+
? 'a shell write in scope whose result this layer cannot compute'
|
|
327
|
+
: 'a shell write in scope on a surface with no pre-state channel to complete it',
|
|
328
|
+
kind: 'no-observation',
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The one common shell-axis skip registration. A write whose target itself is unknowable
|
|
334
|
+
* belongs to no entry's scope, so leaving N rows under N labels would trade one silent pass
|
|
335
|
+
* for a fabricated attribution — one row, one subject `'-'`.
|
|
336
|
+
*/
|
|
337
|
+
function shellUnjudgeableRegistration(spec) {
|
|
338
|
+
const opts = {
|
|
339
|
+
rootDir: spec.rootDir,
|
|
340
|
+
shellTools: spec.shellTools,
|
|
341
|
+
commandArgs: spec.commandArgs,
|
|
342
|
+
readPreState: spec.readPreState,
|
|
343
|
+
};
|
|
344
|
+
return {
|
|
345
|
+
label: 'shell-unjudgeable',
|
|
346
|
+
protectedPaths: [],
|
|
347
|
+
matches: (input) => deriveShellSignals(input, opts).unjudgeable.some((signal) => signal.path === undefined)
|
|
348
|
+
? '-'
|
|
349
|
+
: null,
|
|
350
|
+
skip: {
|
|
351
|
+
reason: 'a shell command whose write target this layer cannot determine',
|
|
352
|
+
kind: 'no-observation',
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Flatten a session into the plain snapshot a declaration reads.
|
|
358
|
+
*
|
|
359
|
+
* `index` is the observation ordinal within its own list, and `observedAtMs` is the clock at
|
|
360
|
+
* supply time — the only moment the age of a turn can be measured against, since the engine
|
|
361
|
+
* itself reads no clock.
|
|
362
|
+
*/
|
|
363
|
+
function snapshotOf(transcript) {
|
|
364
|
+
return {
|
|
365
|
+
observedAtMs: Date.now(),
|
|
366
|
+
userMessages: transcript
|
|
367
|
+
.findUserMessages()
|
|
368
|
+
.map((message, index) => ({ index, text: message.text, timestampMs: message.timestampMs })),
|
|
369
|
+
toolCalls: transcript.findToolCalls().map((call, index) => ({
|
|
370
|
+
index,
|
|
371
|
+
name: call.name,
|
|
372
|
+
args: call.args,
|
|
373
|
+
succeeded: call.succeeded,
|
|
374
|
+
})),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* The `sources` bindings of a declare entry, in declaration order; none is an empty list.
|
|
379
|
+
*
|
|
380
|
+
* Each file path is normalized once, here, so the plan, the supplied keys, and the match
|
|
381
|
+
* against the change set all see one spelling: a `./locales/en.json` an author wrote is
|
|
382
|
+
* otherwise read under one name and looked up under another, and the change's own text never
|
|
383
|
+
* wins. A channel or transcript binding carries its kind instead — neither is a path.
|
|
384
|
+
*/
|
|
385
|
+
function sourceBindings(entry) {
|
|
386
|
+
return Object.entries(entry.declare?.sources ?? {}).map(([name, source]) => {
|
|
387
|
+
if ('sidecar' in source)
|
|
388
|
+
return { name, sidecar: true };
|
|
389
|
+
if ('transcript' in source)
|
|
390
|
+
return { name, transcript: true };
|
|
391
|
+
return { name, file: posix.normalize(source.file) };
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* What each named source is worth on this input: for a file, the change's own `post` when it
|
|
396
|
+
* is one this input changes and the host-supplied text otherwise; for a channel, the text the
|
|
397
|
+
* surface supplied; for a transcript, the injected session flattened into a plain snapshot.
|
|
398
|
+
* Absent when none exists.
|
|
399
|
+
*
|
|
400
|
+
* The change set wins over the supplied text because the two surfaces read the tree at
|
|
401
|
+
* different moments — a session call is judged while the disk still holds the pre-edit
|
|
402
|
+
* state — and the change carries the state the call will produce. A deletion leaves the
|
|
403
|
+
* key absent, since after it there is no file for the declaration's `supply` policy to
|
|
404
|
+
* dispose of by any other reading. That rule never reaches a channel: a channel has no path,
|
|
405
|
+
* so it can never overlap the change set. A transcript has no path either, and its absence
|
|
406
|
+
* is the absence of the injected session — never anything the world axis carries.
|
|
407
|
+
*/
|
|
408
|
+
function sourceValues(bindings, worlds, world, transcript, postIsWholeFile) {
|
|
409
|
+
const values = {};
|
|
410
|
+
for (const binding of bindings) {
|
|
411
|
+
if ('transcript' in binding) {
|
|
412
|
+
// An injected transcript that throws is an unusable channel, not an answer — the
|
|
413
|
+
// same reading the precedent path gives it. Leaving the key absent hands the case
|
|
414
|
+
// to the declaration's `supply` policy instead of the dispatcher's routing failure,
|
|
415
|
+
// which would block an advised entry.
|
|
416
|
+
if (transcript !== undefined) {
|
|
417
|
+
try {
|
|
418
|
+
values[binding.name] = snapshotOf(transcript);
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
// absent
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if ('sidecar' in binding) {
|
|
427
|
+
const text = world?.channels?.sidecar;
|
|
428
|
+
if (text !== undefined)
|
|
429
|
+
values[binding.name] = text;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const { name, file } = binding;
|
|
433
|
+
// Same-path shell writes chain in command order and each carries its own world, so the
|
|
434
|
+
// last one at the path is the state the call leaves. A hunk-only `post` is not that
|
|
435
|
+
// state, so a surface whose changes carry fragments reads the tree below instead — except
|
|
436
|
+
// for a create, whose added lines are the whole file on every surface.
|
|
437
|
+
let changed;
|
|
438
|
+
for (const supplied of worlds) {
|
|
439
|
+
if (supplied.path !== file)
|
|
440
|
+
continue;
|
|
441
|
+
if (postIsWholeFile || !('pre' in supplied.world))
|
|
442
|
+
changed = supplied;
|
|
443
|
+
}
|
|
444
|
+
if (changed !== undefined) {
|
|
445
|
+
if ('post' in changed.world)
|
|
446
|
+
values[name] = changed.world.post;
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const supplied = world?.files?.[file];
|
|
450
|
+
if (supplied !== undefined)
|
|
451
|
+
values[name] = supplied;
|
|
452
|
+
}
|
|
453
|
+
return values;
|
|
454
|
+
}
|
|
455
|
+
/** The first non-pass world of one input, or pass — what the body reports and the valve reads. */
|
|
456
|
+
/**
|
|
457
|
+
* Judge one declaration over the worlds an input admits, first non-pass world wins.
|
|
458
|
+
*
|
|
459
|
+
* A supply-passed world does not stop the loop — a later world may still break, and a break
|
|
460
|
+
* outranks a skip. `supply-pass` is the answer only when no world was judged at all: exit 0
|
|
461
|
+
* with a `passed` row would read as a covenant upheld.
|
|
462
|
+
*/
|
|
463
|
+
function judgeAdmitted(compiled, worlds) {
|
|
464
|
+
let suppliedPast = false;
|
|
465
|
+
let judgedAny = false;
|
|
466
|
+
for (const supplied of worlds) {
|
|
467
|
+
const verdict = judgeDeclaration({ compiled, world: supplied.world });
|
|
468
|
+
if (verdict.kind === 'broken')
|
|
469
|
+
return { kind: 'broken', supplied, breaks: verdict.breaks };
|
|
470
|
+
if (verdict.kind === 'supply-error') {
|
|
471
|
+
return { kind: 'unjudgeable', supplied, source: verdict.source, reason: verdict.reason };
|
|
472
|
+
}
|
|
473
|
+
if (verdict.kind === 'not-applicable' && verdict.reason === 'supply-pass') {
|
|
474
|
+
suppliedPast = true;
|
|
475
|
+
}
|
|
476
|
+
else if (verdict.kind === 'pass') {
|
|
477
|
+
judgedAny = true;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return suppliedPast && !judgedAny ? { kind: 'supply-pass' } : { kind: 'pass' };
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Compile one declaration entry into its registration.
|
|
484
|
+
*
|
|
485
|
+
* An assembly fault is the author's mistake, so it becomes a skip that names its location
|
|
486
|
+
* on stderr and routes nothing: a declaration that could never judge must not record a
|
|
487
|
+
* `skipped` row per change as though the call were at fault. Otherwise routing and judging
|
|
488
|
+
* share the declaration's own scope — the subject is the first world it admits, and the
|
|
489
|
+
* body walks every admitted world in input order, reporting the first that breaks.
|
|
490
|
+
*
|
|
491
|
+
* The declaration's `witness` block joins the injected valve with OR: either the human's
|
|
492
|
+
* pass condition or the declaration's own opens a blocked verdict — the declaration's on
|
|
493
|
+
* the very world the body reported broken, never on an unjudgeable one.
|
|
494
|
+
*/
|
|
495
|
+
function declareRegistration(entry, spec, witness, nameFault, enrich) {
|
|
496
|
+
const compiled = compileEntryDeclaration(entry);
|
|
497
|
+
if (isFault(compiled)) {
|
|
498
|
+
const reason = `${compiled.location}: ${compiled.reason}`;
|
|
499
|
+
nameFault(reason);
|
|
500
|
+
return {
|
|
501
|
+
label: entry.id,
|
|
502
|
+
protectedPaths: [],
|
|
503
|
+
matches: () => null,
|
|
504
|
+
...witness,
|
|
505
|
+
skip: { reason, kind: 'config-fault' },
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
const bindings = sourceBindings(entry);
|
|
509
|
+
const opts = {
|
|
510
|
+
rootDir: spec.rootDir,
|
|
511
|
+
shellTools: spec.shellTools,
|
|
512
|
+
commandArgs: spec.commandArgs,
|
|
513
|
+
readPreState: spec.readPreState,
|
|
514
|
+
};
|
|
515
|
+
// One derivation per input, shared by routing, the body, and the valve: the valve must
|
|
516
|
+
// see the same first non-pass world the body reported — a valve that re-judged on its
|
|
517
|
+
// own could open on a later break while the body had stopped at an unjudgeable world.
|
|
518
|
+
//
|
|
519
|
+
// The input is enriched first, so a computable shell write reaches the declaration as an
|
|
520
|
+
// ordinary file change: without it a Bash call carries no world and the write passes with
|
|
521
|
+
// no row. A path the same command also writes uncomputably is the skip arm's, so its
|
|
522
|
+
// derived world is dropped here. An unreadable pre-state throws out of here and the body
|
|
523
|
+
// turns it into the cannot-judge exit, never a quiet pass.
|
|
524
|
+
//
|
|
525
|
+
// The named sources join the world here rather than in `worldsFromInput`: what a source
|
|
526
|
+
// name means is this declaration's own binding, and the fixed world knows none of them.
|
|
527
|
+
const admittedOf = new WeakMap();
|
|
528
|
+
const admitted = (input) => {
|
|
529
|
+
const cached = admittedOf.get(input);
|
|
530
|
+
if (cached !== undefined)
|
|
531
|
+
return cached;
|
|
532
|
+
const owned = uncomputablePaths(deriveShellSignals(input, opts), spec.rootDir);
|
|
533
|
+
const fixed = worldsFromInput({
|
|
534
|
+
input: enrich(input),
|
|
535
|
+
rootDir: spec.rootDir,
|
|
536
|
+
shellTools: spec.shellTools,
|
|
537
|
+
commandArgs: spec.commandArgs,
|
|
538
|
+
}).filter((supplied) => !owned.has(supplied.path));
|
|
539
|
+
const values = sourceValues(bindings, fixed, input.world, spec.transcript, spec.postIsWholeFile ?? true);
|
|
540
|
+
const worlds = fixed
|
|
541
|
+
.map((supplied) => ({ path: supplied.path, world: { ...supplied.world, ...values } }))
|
|
542
|
+
.filter((supplied) => scopeAdmits(compiled, supplied.world));
|
|
543
|
+
admittedOf.set(input, worlds);
|
|
544
|
+
return worlds;
|
|
545
|
+
};
|
|
546
|
+
// Routing never consults the pre-state reader: it answers a subject, and the reader is
|
|
547
|
+
// the body's channel — asking here would read the disk once per routing pass as well.
|
|
548
|
+
// File-change evidence already carries its own worlds, and a shell write contributes the
|
|
549
|
+
// path it names; whether that write is in scope is settled from `target.path` alone.
|
|
550
|
+
//
|
|
551
|
+
// The shell fallback belongs to a surface that completes such a write. Where none does,
|
|
552
|
+
// the body would be handed no world for it and answer pass, so the write routes to this
|
|
553
|
+
// entry's skip arm instead and the missing channel is what the row records.
|
|
554
|
+
const completes = completesShellWrites(spec);
|
|
555
|
+
const route = (input) => {
|
|
556
|
+
const fixed = worldsFromInput({
|
|
557
|
+
input,
|
|
558
|
+
rootDir: spec.rootDir,
|
|
559
|
+
shellTools: spec.shellTools,
|
|
560
|
+
commandArgs: spec.commandArgs,
|
|
561
|
+
});
|
|
562
|
+
const values = sourceValues(bindings, fixed, input.world, spec.transcript, spec.postIsWholeFile ?? true);
|
|
563
|
+
const matched = fixed.find((supplied) => scopeAdmits(compiled, { ...supplied.world, ...values }))?.path;
|
|
564
|
+
if (matched !== undefined)
|
|
565
|
+
return matched;
|
|
566
|
+
return completes ? firstAdmittedShellWrite(compiled, input, opts, spec.rootDir) : null;
|
|
567
|
+
};
|
|
568
|
+
const judgedOf = new WeakMap();
|
|
569
|
+
const judged = (input) => {
|
|
570
|
+
const cached = judgedOf.get(input);
|
|
571
|
+
if (cached !== undefined)
|
|
572
|
+
return cached;
|
|
573
|
+
const result = judgeAdmitted(compiled, admitted(input));
|
|
574
|
+
judgedOf.set(input, result);
|
|
575
|
+
return result;
|
|
576
|
+
};
|
|
577
|
+
return {
|
|
578
|
+
label: entry.id,
|
|
579
|
+
protectedPaths: [],
|
|
580
|
+
...(entry.declare?.sources !== undefined && { sources: bindings }),
|
|
581
|
+
matches: route,
|
|
582
|
+
enforce: entry.enforce ?? 'advise',
|
|
583
|
+
witness: (input, transcript, ctx) => {
|
|
584
|
+
if (spec.witness?.(input, transcript, ctx) === true)
|
|
585
|
+
return true;
|
|
586
|
+
const judgment = judged(input);
|
|
587
|
+
return (judgment.kind === 'broken' && witnessOpens({ compiled, world: judgment.supplied.world }));
|
|
588
|
+
},
|
|
589
|
+
body: async (input) => {
|
|
590
|
+
try {
|
|
591
|
+
const judgment = judged(input);
|
|
592
|
+
if (judgment.kind === 'broken') {
|
|
593
|
+
return {
|
|
594
|
+
exitCode: 1,
|
|
595
|
+
reason: withWhy(`discipline '${entry.id}' broken on ${judgment.supplied.path}: ${judgment.breaks[0]?.message}`, entry.why),
|
|
596
|
+
witnesses: judgment.breaks,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
if (judgment.kind === 'unjudgeable') {
|
|
600
|
+
process.stderr.write(`discipline '${entry.id}' cannot judge ${judgment.supplied.path}: ${judgment.source} — ${judgment.reason}\n`);
|
|
601
|
+
return UNJUDGEABLE_OUTCOME;
|
|
602
|
+
}
|
|
603
|
+
if (judgment.kind === 'supply-pass') {
|
|
604
|
+
return { exitCode: 0, skipped: 'supply-pass' };
|
|
605
|
+
}
|
|
606
|
+
return { exitCode: 0 };
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
// An input no supply layer could read is unjudgeable, like every other body here.
|
|
610
|
+
return UNJUDGEABLE_OUTCOME;
|
|
611
|
+
}
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Compile validated discipline entries into dispatcher registrations.
|
|
617
|
+
*
|
|
618
|
+
* One registration per entry: `label` = id (per-discipline telemetry), `protectedPaths`
|
|
619
|
+
* = [] (routing is the matches closure, not path mention), `body` = the judge thunk with
|
|
620
|
+
* the entry and the assembly values bound in. Each entry gains a second, body-less
|
|
621
|
+
* registration for its shell axis, and one common `shell-unjudgeable` registration is
|
|
622
|
+
* appended last whatever the entry count.
|
|
623
|
+
*
|
|
624
|
+
* An entry whose declaration does not compile becomes a **skip registration** that routes
|
|
625
|
+
* nothing and names its location on stderr. Assembly never throws — one bad entry taking
|
|
626
|
+
* down its siblings, the meta-covenants, and the witness valve would leave no way to fix
|
|
627
|
+
* the config that caused it.
|
|
628
|
+
*/
|
|
629
|
+
export function compileDisciplineRegistrations(spec) {
|
|
630
|
+
// One enrichment per input, shared by every declaration: the pre-state reader is opened
|
|
631
|
+
// once for a call however many entries judge it, and they all judge the same world.
|
|
632
|
+
//
|
|
633
|
+
// A surface with no pre-state channel completes no shell evidence: the enrichment's every
|
|
634
|
+
// read would answer `undefined`, which means "this location failed" and would block the
|
|
635
|
+
// call. That reading is right for a surface that has the channel and lost one location,
|
|
636
|
+
// and wrong for one that never had it — there the input's own file changes are the whole
|
|
637
|
+
// evidence, and a declaration reading nothing else judges as it would with no shell call.
|
|
638
|
+
const shell = {
|
|
639
|
+
rootDir: spec.rootDir,
|
|
640
|
+
shellTools: spec.shellTools,
|
|
641
|
+
commandArgs: spec.commandArgs,
|
|
642
|
+
readPreState: spec.readPreState,
|
|
643
|
+
};
|
|
644
|
+
const enrichedOf = new WeakMap();
|
|
645
|
+
const enrich = (input) => {
|
|
646
|
+
if (spec.observesPreState === false)
|
|
647
|
+
return input;
|
|
648
|
+
const cached = enrichedOf.get(input);
|
|
649
|
+
if (cached !== undefined)
|
|
650
|
+
return cached;
|
|
651
|
+
const enriched = enrichWithShellEvidence(input, shell);
|
|
652
|
+
enrichedOf.set(input, enriched);
|
|
653
|
+
return enriched;
|
|
654
|
+
};
|
|
655
|
+
const judged = spec.disciplines.map((entry) => {
|
|
656
|
+
const witness = spec.witness !== undefined ? { witness: spec.witness } : {};
|
|
657
|
+
// A silent skip is how a discipline goes inert while its verdict still reads passed.
|
|
658
|
+
const nameFault = (reason) => {
|
|
659
|
+
process.stderr.write(`discipline '${entry.id}': ${reason} — skipped, not judged\n`);
|
|
660
|
+
};
|
|
661
|
+
return declareRegistration(entry, spec, witness, nameFault, enrich);
|
|
662
|
+
});
|
|
663
|
+
const skipArms = spec.disciplines
|
|
664
|
+
.filter((entry) => hasShellSkipArm(entry, spec))
|
|
665
|
+
.map((entry) => shellSkipArm(entry, spec));
|
|
666
|
+
return [...judged, ...skipArms, shellUnjudgeableRegistration(spec)];
|
|
667
|
+
}
|