polydeukes 0.6.1 → 0.8.0

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