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