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.
Files changed (106) hide show
  1. package/README.ko.md +14 -28
  2. package/README.md +14 -28
  3. package/dist/baseline.d.ts +37 -1
  4. package/dist/baseline.js +68 -1
  5. package/dist/bin.d.ts +3 -4
  6. package/dist/bin.js +73 -90
  7. package/dist/covenant/bash-line.d.ts +130 -0
  8. package/dist/covenant/bash-line.js +566 -0
  9. package/dist/covenant/declaration-engine.d.ts +108 -0
  10. package/dist/covenant/declaration-engine.js +415 -0
  11. package/dist/covenant/discipline.d.ts +103 -0
  12. package/dist/covenant/discipline.js +667 -0
  13. package/dist/covenant/dispatch.d.ts +153 -0
  14. package/dist/covenant/dispatch.js +237 -0
  15. package/dist/covenant/extract-steps.d.ts +122 -0
  16. package/dist/covenant/extract-steps.js +0 -0
  17. package/dist/covenant/mention.d.ts +117 -0
  18. package/dist/covenant/mention.js +209 -0
  19. package/dist/covenant/module.d.ts +25 -0
  20. package/dist/covenant/module.js +23 -0
  21. package/dist/covenant/mutation-rules.d.ts +40 -0
  22. package/dist/covenant/mutation-rules.js +146 -0
  23. package/dist/covenant/relations.d.ts +46 -0
  24. package/dist/covenant/relations.js +68 -0
  25. package/dist/covenant/run-covenant.d.ts +95 -0
  26. package/dist/covenant/run-covenant.js +164 -0
  27. package/dist/covenant/self-mod.d.ts +55 -0
  28. package/dist/covenant/self-mod.js +84 -0
  29. package/dist/covenant/shell-evidence.d.ts +38 -0
  30. package/dist/covenant/shell-evidence.js +247 -0
  31. package/dist/covenant/shell-mod.d.ts +84 -0
  32. package/dist/covenant/shell-mod.js +213 -0
  33. package/dist/covenant/supply.d.ts +54 -0
  34. package/dist/covenant/supply.js +50 -0
  35. package/dist/covenant/transcript-mod.d.ts +60 -0
  36. package/dist/covenant/transcript-mod.js +254 -0
  37. package/dist/covenant/ttl-witness.d.ts +47 -0
  38. package/dist/covenant/ttl-witness.js +80 -0
  39. package/dist/covenant-check.d.ts +72 -39
  40. package/dist/covenant-check.js +260 -121
  41. package/dist/diff-ir.d.ts +28 -0
  42. package/dist/diff-ir.js +310 -0
  43. package/dist/docs/README.ko.md +8 -8
  44. package/dist/docs/README.md +8 -8
  45. package/dist/docs/catalog.json +44 -28
  46. package/dist/docs/concepts/judgment.ko.md +2 -2
  47. package/dist/docs/concepts/judgment.md +2 -2
  48. package/dist/docs/how-to/configure-project.ko.md +16 -16
  49. package/dist/docs/how-to/configure-project.md +15 -12
  50. package/dist/docs/how-to/connect-surfaces.ko.md +39 -34
  51. package/dist/docs/how-to/connect-surfaces.md +36 -33
  52. package/dist/docs/how-to/write-disciplines.ko.md +61 -6
  53. package/dist/docs/how-to/write-disciplines.md +62 -6
  54. package/dist/docs/index.json +607 -373
  55. package/dist/docs/reference/cli/covenant-check.ko.md +112 -65
  56. package/dist/docs/reference/cli/covenant-check.md +114 -59
  57. package/dist/docs/reference/cli/docs.ko.md +2 -2
  58. package/dist/docs/reference/cli/docs.md +2 -2
  59. package/dist/docs/reference/cli/explain.ko.md +14 -9
  60. package/dist/docs/reference/cli/explain.md +15 -10
  61. package/dist/docs/reference/cli/init.ko.md +52 -52
  62. package/dist/docs/reference/cli/init.md +43 -55
  63. package/dist/docs/reference/configuration/index.ko.md +171 -66
  64. package/dist/docs/reference/configuration/index.md +178 -71
  65. package/dist/docs/reference/packages/adapter-claude-code.ko.md +38 -11
  66. package/dist/docs/reference/packages/adapter-claude-code.md +38 -14
  67. package/dist/docs/reference/packages/adapter-grok.ko.md +77 -0
  68. package/dist/docs/reference/packages/adapter-grok.md +79 -0
  69. package/dist/docs/reference/packages/core.ko.md +49 -3
  70. package/dist/docs/reference/packages/core.md +54 -5
  71. package/dist/docs/reference/packages/polydeukes.ko.md +138 -77
  72. package/dist/docs/reference/packages/polydeukes.md +146 -81
  73. package/dist/docs/reference/packages/sdk-ts.ko.md +161 -0
  74. package/dist/docs/reference/packages/sdk-ts.md +164 -0
  75. package/dist/docs/troubleshooting.ko.md +17 -15
  76. package/dist/docs/troubleshooting.md +18 -14
  77. package/dist/docs/tutorials/first-judgment.ko.md +2 -2
  78. package/dist/docs/tutorials/first-judgment.md +2 -2
  79. package/dist/explain.d.ts +1 -1
  80. package/dist/explain.js +37 -22
  81. package/dist/load-config.d.ts +1 -1
  82. package/dist/load-config.js +1 -1
  83. package/dist/pre-state-reader.d.ts +1 -1
  84. package/dist/pre-state-reader.js +1 -1
  85. package/dist/scaffold-project.d.ts +2 -2
  86. package/dist/scaffold-project.js +9 -3
  87. package/dist/schema/polydeukes.schema.json +12 -0
  88. package/dist/worktree-reader.d.ts +19 -0
  89. package/dist/worktree-reader.js +30 -0
  90. package/package.json +3 -17
  91. package/dist/claude-code-hook.d.ts +0 -79
  92. package/dist/claude-code-hook.js +0 -372
  93. package/dist/claude-code.d.ts +0 -6
  94. package/dist/claude-code.js +0 -6
  95. package/dist/covenant-module.d.ts +0 -25
  96. package/dist/covenant-module.js +0 -42
  97. package/dist/docs/reference/packages/adapter-git.ko.md +0 -101
  98. package/dist/docs/reference/packages/adapter-git.md +0 -108
  99. package/dist/docs/reference/packages/covenant.ko.md +0 -115
  100. package/dist/docs/reference/packages/covenant.md +0 -123
  101. package/dist/index.d.ts +0 -22
  102. package/dist/index.js +0 -21
  103. package/dist/init-claude-code.d.ts +0 -52
  104. package/dist/init-claude-code.js +0 -586
  105. package/dist/init-grok.d.ts +0 -51
  106. 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
+ }