jev-agent-tools 0.2.0 → 0.3.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 (117) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/CONTRIBUTING.md +3 -0
  3. package/README.md +22 -14
  4. package/SECURITY.md +17 -1
  5. package/dist/adapters/ask-files.js +11 -2
  6. package/dist/adapters/ask-proof.js +63 -7
  7. package/dist/adapters/command.js +82 -29
  8. package/dist/adapters/docs.js +30 -10
  9. package/dist/adapters/evidence-context.js +119 -0
  10. package/dist/adapters/files.js +141 -16
  11. package/dist/adapters/find.js +34 -6
  12. package/dist/adapters/git-base.js +7 -1
  13. package/dist/adapters/git.js +51 -7
  14. package/dist/adapters/locate-file.js +47 -9
  15. package/dist/adapters/private-storage.js +14 -6
  16. package/dist/adapters/risk-callers.js +3 -0
  17. package/dist/adapters/shell.js +23 -7
  18. package/dist/adapters/test-inventory.js +10 -2
  19. package/dist/configuration.js +17 -7
  20. package/dist/constants.js +26 -5
  21. package/dist/core/ask-references.js +193 -109
  22. package/dist/core/asks.js +78 -7
  23. package/dist/core/locate.js +8 -8
  24. package/dist/core/output.js +17 -0
  25. package/dist/core/result-report.js +302 -0
  26. package/dist/core/secret-path.js +34 -0
  27. package/dist/core/state.js +8 -1
  28. package/dist/core/units.js +1 -1
  29. package/dist/jev/client.js +34 -12
  30. package/dist/mcp/protocol.js +50 -27
  31. package/dist/mcp/tools.js +20 -7
  32. package/dist/render.js +72 -0
  33. package/dist/report-schema.js +1356 -0
  34. package/dist/result-types.js +1 -0
  35. package/dist/texts/ask-files.js +3 -1
  36. package/dist/texts/ask.js +3 -1
  37. package/dist/texts/check-diff.js +7 -4
  38. package/dist/texts/find.js +7 -2
  39. package/dist/texts/guide.js +3 -16
  40. package/dist/texts/instructions.js +72 -0
  41. package/dist/texts/locate.js +7 -2
  42. package/dist/texts/select-tests.js +3 -1
  43. package/dist/tools/ask-files.js +248 -15
  44. package/dist/tools/ask.js +523 -62
  45. package/dist/tools/check-diff.js +222 -30
  46. package/dist/tools/docs-check.js +122 -13
  47. package/dist/tools/find.js +320 -27
  48. package/dist/tools/locate.js +317 -18
  49. package/dist/tools/review-report.js +230 -0
  50. package/dist/tools/select-tests.js +273 -19
  51. package/dist/tools/spec-check.js +119 -22
  52. package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +3 -3
  53. package/docs/agent-instructions.md +59 -30
  54. package/docs/design.md +13 -1
  55. package/docs/mcp.md +8 -6
  56. package/docs/tools/jev_ask.md +8 -5
  57. package/docs/tools/jev_ask_files.md +2 -1
  58. package/docs/tools/jev_check_diff.md +4 -1
  59. package/docs/tools/jev_find_files.md +2 -1
  60. package/docs/tools/jev_locate_in_file.md +5 -0
  61. package/docs/tools/jev_select_tests.md +4 -1
  62. package/package.json +1 -1
  63. package/rules/jev-ask.md +22 -1
  64. package/server.json +2 -2
  65. package/src/adapters/ask-files.ts +11 -3
  66. package/src/adapters/ask-proof.ts +69 -11
  67. package/src/adapters/command.ts +96 -33
  68. package/src/adapters/docs.ts +33 -14
  69. package/src/adapters/evidence-context.ts +169 -0
  70. package/src/adapters/files.ts +146 -16
  71. package/src/adapters/find.ts +37 -7
  72. package/src/adapters/git-base.ts +7 -1
  73. package/src/adapters/git.ts +61 -8
  74. package/src/adapters/locate-file.ts +51 -9
  75. package/src/adapters/private-storage.ts +17 -5
  76. package/src/adapters/risk-callers.ts +3 -0
  77. package/src/adapters/shell.ts +23 -7
  78. package/src/adapters/test-inventory.ts +12 -4
  79. package/src/configuration.ts +16 -2
  80. package/src/constants.ts +26 -5
  81. package/src/core/ask-references.ts +262 -146
  82. package/src/core/asks.ts +79 -7
  83. package/src/core/import-boundaries.ts +8 -3
  84. package/src/core/locate.ts +8 -5
  85. package/src/core/output.ts +34 -0
  86. package/src/core/result-report.ts +410 -0
  87. package/src/core/secret-path.ts +37 -0
  88. package/src/core/state.ts +8 -1
  89. package/src/core/units.ts +3 -2
  90. package/src/index.ts +3 -0
  91. package/src/jev/client.ts +54 -16
  92. package/src/jev/types.ts +18 -3
  93. package/src/mcp/protocol.ts +91 -41
  94. package/src/mcp/tools.ts +26 -13
  95. package/src/render.ts +109 -0
  96. package/src/report-schema.ts +1380 -0
  97. package/src/result-types.ts +234 -0
  98. package/src/result.ts +4 -1
  99. package/src/runtime.ts +6 -0
  100. package/src/texts/ask-files.ts +4 -1
  101. package/src/texts/ask.ts +8 -1
  102. package/src/texts/check-diff.ts +7 -4
  103. package/src/texts/find.ts +8 -2
  104. package/src/texts/guide.ts +8 -16
  105. package/src/texts/instructions.ts +98 -0
  106. package/src/texts/locate.ts +8 -2
  107. package/src/texts/run-end.ts +2 -2
  108. package/src/texts/select-tests.ts +4 -1
  109. package/src/tools/ask-files.ts +309 -14
  110. package/src/tools/ask.ts +700 -77
  111. package/src/tools/check-diff.ts +331 -28
  112. package/src/tools/docs-check.ts +241 -39
  113. package/src/tools/find.ts +386 -29
  114. package/src/tools/locate.ts +384 -19
  115. package/src/tools/review-report.ts +308 -0
  116. package/src/tools/select-tests.ts +479 -21
  117. package/src/tools/spec-check.ts +193 -19
@@ -9,108 +9,185 @@ function unquote(value) {
9
9
  return parsed;
10
10
  }
11
11
  catch {
12
- return value.slice(1, -1);
12
+ /* retain the literal selector */
13
13
  }
14
14
  }
15
15
  return value.slice(1, -1).replace(/\\(['\\])/g, "$1");
16
16
  }
17
- function looksLikePath(value) {
18
- if (/\s/.test(value) || /^(?:[a-z]+:|www\.)/i.test(value))
19
- return false;
20
- if (!/[A-Za-z_]/.test(value) ||
21
- /^(?:state|docs?|files|files_before)\./.test(value))
22
- return false;
23
- if (/^(?:e\.g|i\.e|Node\.js|Next\.js|Vue\.js)$/i.test(value))
24
- return false;
25
- return (/^(?:\.?\.?\/)?[\w.@+-]+(?:\/[\w.@+-]+)+$/.test(value) ||
26
- /^[\w@+-][\w.@+-]*\.(?:[cm]?[jt]sx?|py|json|jsonc|ya?ml|toml|ini|cfg|conf|md|mdx|txt|rst|html?|css|scss|sql|sh|bash|rs|go|java|rb|php|c|h|cpp|hpp|vue|svelte|xml|csv|env|lock)$/i.test(value) ||
27
- /^\.[A-Za-z][\w.-]*$/.test(value) ||
28
- /^(?:README|LICENSE|LICENCE|Makefile|Dockerfile|CHANGELOG|AGENTS|CLAUDE)(?:\.[\w-]+)?$/.test(value));
17
+ function normalized(path) {
18
+ return path.replace(/\\/g, "/").replace(/^(?:\.\/)+/, "");
19
+ }
20
+ function forbidden(path) {
21
+ return (/^(?:\/|[A-Za-z]:|[a-z][a-z\d+.-]*:\/\/)/i.test(path) ||
22
+ path.split(/[\\/]/).some((part) => part === ".." || part === ".git"));
23
+ }
24
+ function explicitPaths(text) {
25
+ const references = new Map();
26
+ for (const match of text.matchAll(fileSelector)) {
27
+ const reference = unquote(match[2] ?? "");
28
+ const side = match[1];
29
+ references.set(JSON.stringify([side, reference]), { reference, side });
30
+ }
31
+ return [...references.values()];
29
32
  }
30
- /** Explicit paths only: numbers, dates and URLs are not evidence references. */
33
+ /** Lexical discovery hints, never requirements. Ordinary member expressions are excluded. */
31
34
  export function citedPaths(text) {
32
- const found = new Set();
33
- const withoutUrls = text.replace(/\b(?:[a-z][a-z\d+.-]*:\/\/|www\.)[^\s<>"'`]+/gi, " ");
34
- const withoutSelectors = withoutUrls.replace(fileSelector, (_whole, _root, quoted) => {
35
- found.add(unquote(quoted));
36
- return " ";
37
- });
38
- const withoutFields = withoutSelectors.replace(selector, (value) => value.includes("[") || /^(?:state|docs?|files|files_before)\./.test(value)
35
+ const found = new Set(explicitPaths(text).map(({ reference }) => reference));
36
+ const cleaned = text
37
+ .replace(/\b(?:[a-z][a-z\d+.-]*:\/\/|www\.)[^\s<>"'`]+/gi, " ")
38
+ .replace(fileSelector, " ")
39
+ .replace(selector, (value) => value.includes("[") ||
40
+ /^(?:state|output|docs?|files|files_before)\./.test(value)
39
41
  ? " "
40
42
  : value);
41
- const unquoted = withoutFields.replace(/`([^`\n]+)`|"([^"\n]+)"|'([^'\n]+)'/g, (_whole, backtick, double, single) => {
42
- const value = backtick ?? double ?? single ?? "";
43
- if (looksLikePath(value)) {
44
- found.add(value);
45
- return " ";
46
- }
47
- return value;
48
- });
49
- for (const match of unquoted.matchAll(/[\w.@+-]+(?:\/[\w.@+-]+)*/g)) {
50
- const value = match[0].replace(/[.,;:!?]+$/, "");
51
- if (looksLikePath(value) &&
52
- (!value.includes("/") ||
53
- looksLikePath(value.slice(value.lastIndexOf("/") + 1))))
43
+ for (const match of cleaned.matchAll(/`([^`\n]+)`|"([^"\n]+)"|'([^'\n]+)'/g)) {
44
+ const value = match[1] ?? match[2] ?? match[3] ?? "";
45
+ if (/^(?:[\w@+-]+\/)+[\w.@+-]+$/.test(value))
54
46
  found.add(value);
55
47
  }
48
+ for (const match of cleaned.matchAll(/\b(?:README|LICENSE|LICENCE|Makefile|Dockerfile|CHANGELOG|AGENTS|CLAUDE)(?:\.[\w-]+)?\b/g))
49
+ found.add(match[0]);
50
+ for (const match of cleaned.matchAll(/(?:\.?\.?\/)?[\w@+-]+(?:\/[\w.@+-]+)*\.(?:[cm]?[jt]sx?|py|jsonc?|ya?ml|toml|ini|cfg|conf|mdx?|txt|rst|html?|css|scss|sql|sh|bash|rs|go|java|rb|php|cpp|hpp|vue|svelte|xml|csv|lock)\b/g)) {
51
+ const start = match.index ?? 0;
52
+ if ((start && /[\w.]/.test(cleaned[start - 1] ?? "")) ||
53
+ /^(?:Node|Next|Vue)\.js$/i.test(match[0]))
54
+ continue;
55
+ found.add(match[0]);
56
+ }
56
57
  return [...found];
57
58
  }
58
59
  function questionText(question) {
59
- const criteria = question.criteria;
60
60
  return [
61
61
  question.instructions,
62
- ...(criteria ? Object.values(criteria) : []),
62
+ ...Object.values(question.criteria ?? {}),
63
63
  ].join("\n");
64
64
  }
65
65
  function readingTexts(plan) {
66
66
  return plan.readings.map((reading) => ({
67
67
  id: reading.id,
68
+ about: plan.asks[reading.askIndex]?.about,
68
69
  text: [reading.id, reading.twin, ...Object.values(reading.controls ?? {})]
69
70
  .flatMap((id) => id && plan.questions[id] ? [questionText(plan.questions[id])] : [])
70
71
  .join("\n"),
71
72
  }));
72
73
  }
73
- function normalized(path) {
74
- return path.replace(/^(?:\.\/)+/, "");
75
- }
76
- export function resolveAskReferences(plan, note, files, inventory) {
77
- const references = new Set(readingTexts(plan).flatMap(({ text }) => citedPaths(text)));
78
- for (const reference of citedPaths(note ?? ""))
79
- references.add(reference);
80
- const paths = [...new Set([...inventory, ...Object.keys(files)])];
81
- const exact = new Map(paths.map((path) => [normalized(path), path]));
82
- const selected = new Set(Object.keys(files).map(normalized));
74
+ export function resolveAskReferences(plan, note, evidence, inventory) {
75
+ const index = evidence.files !== null && typeof evidence.files === "object"
76
+ ? evidence
77
+ : { files: evidence };
78
+ const supplied = [
79
+ ...new Set([
80
+ ...Object.keys(index.files),
81
+ ...Object.keys(index.files_before ?? {}),
82
+ ]),
83
+ ].sort();
84
+ const currentKnown = [...new Set(inventory)].sort();
85
+ const beforeKnown = [
86
+ ...new Set([...inventory, ...(index.beforeInventory ?? [])]),
87
+ ].sort();
88
+ const selected = new Set(supplied.map(normalized));
89
+ const versions = new Set([
90
+ ...Object.keys(index.files).map((path) => `files:${normalized(path)}`),
91
+ ...Object.keys(index.files_before ?? {}).map((path) => `files_before:${normalized(path)}`),
92
+ ]);
83
93
  const additions = [];
84
94
  const unresolved = [];
85
- for (const reference of references) {
86
- if (Object.hasOwn(files, reference))
87
- continue;
88
- const key = normalized(reference);
89
- const direct = exact.get(key);
90
- const matches = direct
91
- ? [direct]
92
- : paths.filter((path) => normalized(path).endsWith(`/${key}`));
93
- if (matches.length !== 1) {
94
- unresolved.push({
95
- reference,
96
- reason: `${reference} is cited but missing from state (${matches.length ? "ambiguous path" : "not found"})`,
97
- });
98
- continue;
99
- }
100
- const path = matches[0];
101
- if (path === undefined)
102
- continue;
103
- if (!selected.has(normalized(path)) && selected.size >= ASK_MAX_FILES) {
104
- unresolved.push({
95
+ const invalid = [];
96
+ const aliases = Object.create(null);
97
+ const sources = [
98
+ ...readingTexts(plan).map(({ id, text, about }) => ({
99
+ text: `${about ?? ""}\n${text}`,
100
+ scope: id,
101
+ origin: "question",
102
+ output: about === "output",
103
+ })),
104
+ {
105
+ text: note ?? "",
106
+ scope: "global",
107
+ origin: "note",
108
+ output: false,
109
+ },
110
+ ];
111
+ for (const source of sources) {
112
+ const explicit = explicitPaths(source.text);
113
+ const explicitNames = new Set(explicit.map(({ reference }) => reference));
114
+ const references = [
115
+ ...explicit.map((ref) => ({ ...ref, required: true })),
116
+ ...(source.output
117
+ ? []
118
+ : citedPaths(source.text)
119
+ .filter((reference) => !explicitNames.has(reference))
120
+ .map((reference) => ({
121
+ reference,
122
+ side: "files",
123
+ required: false,
124
+ }))),
125
+ ];
126
+ for (const ref of references) {
127
+ const { reference, required, side } = ref;
128
+ const known = side === "files_before" ? beforeKnown : currentKnown;
129
+ const facts = {
105
130
  reference,
106
- reason: `${reference} is cited but missing from state (ASK_MAX_FILES limit: ${ASK_MAX_FILES})`,
107
- });
108
- continue;
131
+ required,
132
+ side,
133
+ origin: source.origin,
134
+ scope: source.scope,
135
+ };
136
+ if (forbidden(reference)) {
137
+ if (required)
138
+ invalid.push({
139
+ ...facts,
140
+ reason: `Path not permitted: ${reference}`,
141
+ });
142
+ continue;
143
+ }
144
+ const key = normalized(reference);
145
+ // Directory-bearing explicit paths are exact, never suffix fallbacks.
146
+ const exact = supplied.find((path) => normalized(path) === key);
147
+ const abbreviated = !key.includes("/");
148
+ let matches = exact ? [exact] : [];
149
+ if (!matches.length && abbreviated) {
150
+ const alias = index.aliases && Object.hasOwn(index.aliases, reference)
151
+ ? index.aliases[reference]
152
+ : undefined;
153
+ if (alias !== undefined)
154
+ matches = (typeof alias === "string" ? [alias] : [...alias]).filter((path) => supplied.includes(path));
155
+ if (!matches.length)
156
+ matches = supplied.filter((path) => normalized(path).endsWith(`/${key}`));
157
+ }
158
+ if (!matches.length)
159
+ matches = known.filter((path) => normalized(path) === key);
160
+ if (!matches.length && abbreviated)
161
+ matches = known.filter((path) => normalized(path).endsWith(`/${key}`));
162
+ if (matches.length !== 1) {
163
+ if (required)
164
+ unresolved.push({
165
+ ...facts,
166
+ reason: `${reference} is required but missing from state (${matches.length ? "ambiguous path" : "not found"})`,
167
+ });
168
+ continue;
169
+ }
170
+ const path = matches[0];
171
+ if (path === undefined)
172
+ continue;
173
+ aliases[reference] = path;
174
+ const identity = normalized(path);
175
+ const version = `${side}:${identity}`;
176
+ if (versions.has(version))
177
+ continue;
178
+ if (!selected.has(identity) && selected.size >= ASK_MAX_FILES) {
179
+ unresolved.push({
180
+ ...facts,
181
+ reason: `${reference} omitted (ASK_MAX_FILES limit: ${ASK_MAX_FILES})`,
182
+ });
183
+ continue;
184
+ }
185
+ selected.add(identity);
186
+ versions.add(version);
187
+ additions.push({ ...facts, path });
109
188
  }
110
- selected.add(normalized(path));
111
- additions.push({ reference, path });
112
189
  }
113
- return { additions, unresolved };
190
+ return { additions, unresolved, aliases, invalid };
114
191
  }
115
192
  function object(value) {
116
193
  return value !== null && typeof value === "object";
@@ -126,69 +203,76 @@ function unpack(value) {
126
203
  }
127
204
  }
128
205
  function empty(value) {
129
- return (value === null ||
206
+ return (value === undefined ||
207
+ value === null ||
130
208
  (typeof value === "string" && value.trim() === "") ||
131
209
  (object(value) && Object.keys(value).length === 0));
132
210
  }
133
211
  function emptyReference(text, state) {
134
- // File references can be plain paths, not just selectors.
135
- const files = state.files;
136
- if (object(files)) {
137
- for (const reference of citedPaths(text)) {
138
- if (Object.hasOwn(files, reference) && empty(files[reference]))
139
- return reference;
140
- }
141
- }
212
+ const metadata = object(state.evidence) ? state.evidence : {};
213
+ const aliases = object(metadata.aliases) ? metadata.aliases : {};
142
214
  for (const match of text.matchAll(selector)) {
143
215
  const parts = [
144
216
  ...match[0].matchAll(/[A-Za-z_$][\w$]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\d+/g),
145
217
  ].map(([part]) => (/^["']/.test(part) ? unquote(part) : part));
146
218
  const root = parts[0];
147
- if (root === undefined)
219
+ if (!root)
148
220
  continue;
149
- let value;
150
- let offset;
151
- if (root === "state") {
152
- value = state;
153
- offset = 1;
154
- }
155
- else if (Object.hasOwn(state, root)) {
156
- value = state[root];
157
- offset = 1;
158
- }
159
- else
221
+ if (!["state", "output", "files", "files_before"].includes(root) &&
222
+ !Object.hasOwn(state, root))
160
223
  continue;
161
- let present = true;
162
- for (const part of parts.slice(offset)) {
224
+ if (!/[.[]/.test(match[0]) && text.trim() !== match[0].trim())
225
+ continue;
226
+ let value = root === "state"
227
+ ? Object.hasOwn(state, "state")
228
+ ? state.state
229
+ : state
230
+ : state[root];
231
+ const tail = parts.slice(1);
232
+ if ((root === "files" || root === "files_before") &&
233
+ tail[0] &&
234
+ object(value) &&
235
+ !Object.hasOwn(value, tail[0])) {
236
+ const alias = Object.hasOwn(aliases, tail[0])
237
+ ? aliases[tail[0]]
238
+ : undefined;
239
+ if (typeof alias === "string")
240
+ tail[0] = alias;
241
+ }
242
+ for (const part of tail) {
163
243
  value = unpack(value);
164
- if (!object(value) || !Object.hasOwn(value, part)) {
165
- present = false;
166
- break;
167
- }
168
- value = value[part];
244
+ value =
245
+ object(value) && Object.hasOwn(value, part) ? value[part] : undefined;
169
246
  }
170
- // A file absent from the base proves creation; it is not empty evidence.
171
- if (root === "files_before" && value === null)
247
+ // null before is admitted only by the base reader for a tracked-tree absence.
248
+ if (root === "files_before" &&
249
+ value === null &&
250
+ typeof state.base_sha === "string" &&
251
+ state.base_sha.length > 0)
172
252
  continue;
173
- if (present && empty(unpack(value)))
253
+ if (empty(root === "files" || root === "files_before" ? value : unpack(value)))
174
254
  return match[0].trim();
175
255
  }
176
256
  return undefined;
177
257
  }
178
258
  export function blockedAskReadings(plan, state, unresolved, note) {
179
259
  const blocked = new Map();
180
- const notePaths = new Set(citedPaths(note ?? ""));
181
260
  const noteEmpty = emptyReference(note ?? "", state);
182
- for (const { id, text } of readingTexts(plan)) {
183
- const paths = new Set(citedPaths(text));
184
- const missing = unresolved.find(({ reference }) => paths.has(reference) || notePaths.has(reference));
261
+ for (const { id, text, about } of readingTexts(plan)) {
262
+ const explicit = new Set(explicitPaths(`${about ?? ""}\n${text}`).map(({ reference }) => reference));
263
+ const missing = unresolved.find((ref) => ref.required !== false &&
264
+ (ref.scope === "global" ||
265
+ ref.scope === id ||
266
+ (ref.scope === undefined && explicit.has(ref.reference))));
185
267
  if (missing) {
186
268
  blocked.set(id, missing.reason);
187
269
  continue;
188
270
  }
189
- const reference = noteEmpty ?? emptyReference(text, state);
271
+ const reference = noteEmpty ??
272
+ (about ? emptyReference(about, state) : undefined) ??
273
+ emptyReference(text, state);
190
274
  if (reference)
191
- blocked.set(id, `${reference} is empty`);
275
+ blocked.set(id, `${reference} is missing or empty`);
192
276
  }
193
277
  return blocked;
194
278
  }
package/dist/core/asks.js CHANGED
@@ -335,8 +335,7 @@ export function readAsks(plan, answers, reversed = {}) {
335
335
  .join("; ");
336
336
  result.push({
337
337
  label: r.label,
338
- value: { head: "unjudged", p: 0 },
339
- band: "unsure",
338
+ unjudged: true,
340
339
  reason: `${reason}; ${r.missing}`,
341
340
  uncalibrated: r.uncalibrated,
342
341
  });
@@ -350,7 +349,22 @@ export function readAsks(plan, answers, reversed = {}) {
350
349
  : undefined;
351
350
  result.push({
352
351
  label: level ? `${r.label} ${JSON.stringify(level)}` : r.label,
352
+ source: answer.source,
353
+ ...(answer.type === "bool" ? { publicResult: answer.p >= 0.5 } : {}),
353
354
  answer,
355
+ rawValues: answer.type === "bool"
356
+ ? [
357
+ {
358
+ label: "bool",
359
+ value: answer.p >= 0.5,
360
+ probability: { status: "known", value: answer.p },
361
+ },
362
+ ]
363
+ : Object.entries(answer.probabilities).map(([label, value]) => ({
364
+ label,
365
+ value: label,
366
+ probability: { status: "known", value },
367
+ })),
354
368
  uncalibrated: r.uncalibrated,
355
369
  });
356
370
  continue;
@@ -404,13 +418,22 @@ export function readAsks(plan, answers, reversed = {}) {
404
418
  }
405
419
  }
406
420
  if (Object.values(r.controls ?? {}).some((id) => answers[id]?.type !== "bool")) {
407
- band = "unsure";
408
- reason =
409
- "coherence control unjudged; no verdict: rerun with decisive evidence";
421
+ result.push({
422
+ label: r.label,
423
+ unjudged: true,
424
+ reason: "required coherence control has no valid bool response",
425
+ missing: r.missing,
426
+ });
427
+ continue;
410
428
  }
411
429
  if (!r.twin && Object.hasOwn(reversed, r.id) && !second) {
412
- band = "unsure";
413
- reason = `reverse-order control unjudged; no verdict: ${r.missing}`;
430
+ result.push({
431
+ label: r.label,
432
+ unjudged: true,
433
+ reason: "required reverse-order control unjudged",
434
+ missing: r.missing,
435
+ });
436
+ continue;
414
437
  }
415
438
  const firstMerged = merged(original, r.kind === "verify");
416
439
  const secondMerged = second
@@ -422,6 +445,54 @@ export function readAsks(plan, answers, reversed = {}) {
422
445
  ? `${r.label} ${JSON.stringify(r.options[head])}`
423
446
  : r.label,
424
447
  answer: { ...answer, probabilities: p, choice: head },
448
+ source: answer.source,
449
+ rawValues: Object.entries(original).map(([label, value]) => ({
450
+ label,
451
+ value: label,
452
+ probability: { status: "known", value },
453
+ })),
454
+ reportControls: [
455
+ ...members.slice(1).map(([id, label]) => ({
456
+ id,
457
+ label,
458
+ answer: answers[id],
459
+ kind: "cross_check",
460
+ })),
461
+ ...(!r.twin && reversed[r.id]
462
+ ? [
463
+ {
464
+ id: `${r.id}:reverse`,
465
+ label: "reverse order",
466
+ answer: reversed[r.id],
467
+ kind: "order",
468
+ },
469
+ ]
470
+ : []),
471
+ ].flatMap(({ id, label, answer: a, kind }) => {
472
+ if (!a || a.type === "unjudged" || !a.source)
473
+ return [];
474
+ return [
475
+ {
476
+ id,
477
+ kind,
478
+ source: a.source,
479
+ outcome: label,
480
+ rawValues: a.type === "bool"
481
+ ? [
482
+ {
483
+ label,
484
+ value: a.p >= 0.5,
485
+ probability: { status: "known", value: a.p },
486
+ },
487
+ ]
488
+ : Object.entries(a.probabilities).map(([key, value]) => ({
489
+ label: key,
490
+ value: key,
491
+ probability: { status: "known", value },
492
+ })),
493
+ },
494
+ ];
495
+ }),
425
496
  value: { head, p: mass },
426
497
  band,
427
498
  reason,
@@ -11,13 +11,13 @@ export function sectionState(path, goal, sections, outline = false) {
11
11
  })),
12
12
  };
13
13
  }
14
- export function sectionStateFits(path, goal, sections, outline = false) {
14
+ export function sectionStateFits(path, goal, sections, outline = false, maxChars = STATE_MAX_CHARS) {
15
15
  return (sections.length < CHOICE_MAX_OPTIONS &&
16
16
  JSON.stringify(sectionState(path, goal, sections, outline)).length <=
17
- STATE_MAX_CHARS);
17
+ maxChars);
18
18
  }
19
19
  /** Every planned block has at least two sections and fits its full-text refinement budget. */
20
- export function sectionOutline(path, goal, sections) {
20
+ export function sectionOutline(path, goal, sections, maxChars = STATE_MAX_CHARS) {
21
21
  const base = JSON.stringify(sectionState(path, goal, [])).length;
22
22
  const costs = new Map(sections.map((section) => [
23
23
  section,
@@ -31,16 +31,16 @@ export function sectionOutline(path, goal, sections) {
31
31
  ]));
32
32
  const fits = (items) => items.length < CHOICE_MAX_OPTIONS &&
33
33
  base + items.reduce((sum, item) => sum + (costs.get(item) ?? 0) + 1, 0) <=
34
- STATE_MAX_CHARS;
34
+ maxChars;
35
35
  const groups = [];
36
36
  let current = [], currentSize = base;
37
37
  for (const section of sections) {
38
38
  const length = (costs.get(section) ?? 0) + 1;
39
- if (base + length > STATE_MAX_CHARS)
39
+ if (base + length > maxChars)
40
40
  return [];
41
41
  const owner = section.label.split(".")[0], previousOwner = current.at(-1)?.label.split(".")[0];
42
42
  if (current.length &&
43
- (currentSize + length > STATE_MAX_CHARS ||
43
+ (currentSize + length > maxChars ||
44
44
  current.length + 1 >= CHOICE_MAX_OPTIONS ||
45
45
  (current.length >= 2 &&
46
46
  section.label.includes(".") &&
@@ -98,9 +98,9 @@ export function sectionOutline(path, goal, sections) {
98
98
  }));
99
99
  }
100
100
  /** JSON-escaped budget, allocated equally then redistributed to larger blocks. */
101
- export function outlineEvidence(path, goal, blocks) {
101
+ export function outlineEvidence(path, goal, blocks, maxChars = STATE_MAX_CHARS) {
102
102
  const overhead = JSON.stringify(sectionState(path, goal, blocks.map((block) => ({ ...block, text: "" })))).length;
103
- let remaining = Math.max(0, STATE_MAX_CHARS - overhead);
103
+ let remaining = Math.max(0, maxChars - overhead);
104
104
  const allocations = blocks.map(() => 0);
105
105
  let active = blocks.map((_, index) => index);
106
106
  while (remaining > 0 && active.length) {
@@ -38,6 +38,15 @@ export function buildEnvelope(input) {
38
38
  .map((control) => control.fact)
39
39
  .join("; ");
40
40
  for (const item of input.answers ?? []) {
41
+ if (item.unjudged) {
42
+ lines.push({
43
+ type: "unjudged",
44
+ label: item.label,
45
+ reason: item.reason ?? "required group answer missing",
46
+ next: item.missing ?? "inspect the available evidence without a judgment",
47
+ });
48
+ continue;
49
+ }
41
50
  const value = displayValue(item);
42
51
  const initial = item.band ?? (input.policy ?? askBand)(value, item);
43
52
  const band = initial === "verdict" && failed ? "unsure" : initial;
@@ -54,6 +63,14 @@ export function buildEnvelope(input) {
54
63
  value,
55
64
  band,
56
65
  uncalibrated: item.uncalibrated ?? false,
66
+ ...((item.source ?? item.answer?.source)
67
+ ? { source: item.source ?? item.answer?.source }
68
+ : {}),
69
+ ...(item.publicResult !== undefined
70
+ ? { publicResult: item.publicResult }
71
+ : {}),
72
+ ...(item.rawValues ? { rawValues: item.rawValues } : {}),
73
+ ...(item.reportControls ? { reportControls: item.reportControls } : {}),
57
74
  ...(reason ? { reason } : {}),
58
75
  ...(item.orderDependent !== undefined
59
76
  ? { orderDependent: item.orderDependent }