@agwab/pi-workflow 0.1.1 → 0.2.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 (70) hide show
  1. package/README.md +20 -15
  2. package/agents/researcher.md +17 -7
  3. package/dist/artifact-graph-runtime.js +1 -0
  4. package/dist/compiler.d.ts +2 -0
  5. package/dist/compiler.js +29 -4
  6. package/dist/dynamic-generated-task-runtime.js +4 -3
  7. package/dist/dynamic-runtime-bundle.js +3 -2
  8. package/dist/engine.d.ts +2 -0
  9. package/dist/engine.js +3 -2
  10. package/dist/extension.js +240 -16
  11. package/dist/store.js +1 -0
  12. package/dist/subagent-backend.js +82 -27
  13. package/dist/tool-metadata.d.ts +1 -0
  14. package/dist/tool-metadata.js +13 -1
  15. package/dist/types.d.ts +3 -0
  16. package/dist/workflow-artifact-extension.js +3 -2
  17. package/dist/workflow-artifact-tool.js +84 -4
  18. package/dist/workflow-progress-health.d.ts +37 -0
  19. package/dist/workflow-progress-health.js +296 -0
  20. package/dist/workflow-runtime.d.ts +6 -0
  21. package/dist/workflow-runtime.js +33 -10
  22. package/dist/workflow-view.d.ts +2 -0
  23. package/dist/workflow-view.js +97 -18
  24. package/dist/workflow-web-source-extension.d.ts +43 -0
  25. package/dist/workflow-web-source-extension.js +1194 -0
  26. package/dist/workflow-web-source.d.ts +171 -0
  27. package/dist/workflow-web-source.js +915 -0
  28. package/docs/usage.md +32 -18
  29. package/node_modules/@agwab/pi-subagent/package.json +1 -1
  30. package/node_modules/@agwab/pi-subagent/src/api.ts +245 -132
  31. package/node_modules/@agwab/pi-subagent/src/artifacts/result.ts +243 -163
  32. package/node_modules/@agwab/pi-subagent/src/core/constants.ts +117 -90
  33. package/node_modules/@agwab/pi-subagent/src/core/validation.ts +728 -475
  34. package/node_modules/@agwab/pi-subagent/src/orchestrate/run.ts +305 -209
  35. package/node_modules/@agwab/pi-subagent/src/runners/headless-model.ts +750 -439
  36. package/node_modules/@agwab/pi-subagent/src/runners/tmux.ts +422 -268
  37. package/package.json +7 -7
  38. package/skills/workflow-guide/scaffolds/object-tool-fallback/schemas/fetch-control.schema.json +1 -1
  39. package/skills/workflow-guide/scaffolds/object-tool-fallback/spec.json +4 -3
  40. package/src/artifact-graph-runtime.ts +1 -0
  41. package/src/compiler.ts +43 -3
  42. package/src/dynamic-generated-task-runtime.ts +4 -2
  43. package/src/dynamic-runtime-bundle.ts +3 -2
  44. package/src/engine.ts +7 -16
  45. package/src/extension.ts +299 -22
  46. package/src/store.ts +1 -0
  47. package/src/subagent-backend.ts +121 -37
  48. package/src/tool-metadata.ts +22 -1
  49. package/src/types.ts +4 -0
  50. package/src/workflow-artifact-extension.ts +3 -2
  51. package/src/workflow-artifact-tool.ts +96 -4
  52. package/src/workflow-progress-health.ts +461 -0
  53. package/src/workflow-runtime.ts +50 -13
  54. package/src/workflow-view.ts +186 -41
  55. package/src/workflow-web-source-extension.ts +1411 -0
  56. package/src/workflow-web-source.ts +1294 -0
  57. package/workflows/README.md +1 -1
  58. package/workflows/deep-research/helpers/claim-evidence-gate.mjs +552 -44
  59. package/workflows/deep-research/helpers/final-audit-packet.mjs +396 -0
  60. package/workflows/deep-research/helpers/normalize-input-packet.mjs +545 -0
  61. package/workflows/deep-research/helpers/render-executive.mjs +1199 -192
  62. package/workflows/deep-research/helpers/sanitize-verification-candidates.mjs +624 -0
  63. package/workflows/deep-research/schemas/deep-research-executive-render-control.schema.json +37 -8
  64. package/workflows/deep-research/schemas/deep-research-final-synthesis-control.schema.json +110 -0
  65. package/workflows/deep-research/schemas/deep-research-normalize-claims-control.schema.json +45 -4
  66. package/workflows/deep-research/schemas/deep-research-verify-claims-control.schema.json +0 -2
  67. package/workflows/deep-research/spec.json +71 -26
  68. package/workflows/deep-review/helpers/render-review-report.mjs +502 -0
  69. package/workflows/deep-review/schemas/deep-review-render-control.schema.json +50 -0
  70. package/workflows/deep-review/spec.json +22 -1
@@ -1,7 +1,7 @@
1
- // Deterministic executive renderer for deep-research-compact-v2.
1
+ // Deterministic evidence-backed renderer for deep-research.
2
2
  //
3
3
  // Input: final-audit.control.json from the full deep-research final stage.
4
- // Output: compact executiveMarkdown in control plus an executive.md sidecar.
4
+ // Output: a parent-facing research report in executiveMarkdown plus sidecars.
5
5
  //
6
6
  // This intentionally treats final-audit.control.json as the source of truth and
7
7
  // renders a bounded view. It does not re-verify or invent evidence.
@@ -10,16 +10,59 @@ import { mkdir, writeFile } from "node:fs/promises";
10
10
  import { join } from "node:path";
11
11
 
12
12
  function findSource(sources, stageId) {
13
- for (const [specId, source] of Object.entries(sources ?? {})) {
14
- if (specId === stageId || specId.startsWith(`${stageId}.`)) return source;
15
- }
16
- return null;
13
+ const entries = Object.entries(sources ?? {});
14
+ const exact = entries.find(([specId]) => specId === stageId);
15
+ if (exact) return exact[1];
16
+ const dotted = entries.find(([specId]) => specId.startsWith(`${stageId}.`));
17
+ return dotted?.[1] ?? null;
17
18
  }
18
19
 
19
20
  function asArray(value) {
20
21
  return Array.isArray(value) ? value : [];
21
22
  }
22
23
 
24
+ function isRecord(value) {
25
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
26
+ }
27
+
28
+ function flattenItems(value) {
29
+ if (Array.isArray(value)) return value.flatMap((item) => flattenItems(item));
30
+ if (typeof value === "string") return value.trim() ? [value] : [];
31
+ if (!isRecord(value)) return [];
32
+ const renderFields = [
33
+ "gap",
34
+ "finding",
35
+ "claim",
36
+ "note",
37
+ "reason",
38
+ "nextStep",
39
+ "evidenceState",
40
+ "whyItMatters",
41
+ "parentImpact",
42
+ "recommendation",
43
+ "action",
44
+ "step",
45
+ ];
46
+ if (
47
+ renderFields.some(
48
+ (field) => typeof value[field] === "string" && value[field].trim(),
49
+ )
50
+ ) {
51
+ return [value];
52
+ }
53
+ if (
54
+ value.id ||
55
+ value.gapId ||
56
+ value.slotId ||
57
+ Array.isArray(value.relatedFactSlotIds) ||
58
+ Array.isArray(value.sourceUrls) ||
59
+ Array.isArray(value.sourceRefs)
60
+ ) {
61
+ return [value];
62
+ }
63
+ return Object.values(value).flatMap((item) => flattenItems(item));
64
+ }
65
+
23
66
  function words(text) {
24
67
  return (
25
68
  String(text ?? "")
@@ -39,6 +82,52 @@ function cleanText(value) {
39
82
  .trim();
40
83
  }
41
84
 
85
+ function escapeTableCell(value) {
86
+ return cleanText(value).replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
87
+ }
88
+
89
+ function stringifyItem(item) {
90
+ if (typeof item === "string") return cleanText(item) || "(empty string)";
91
+ try {
92
+ const json = JSON.stringify(item);
93
+ if (json) return cleanText(json);
94
+ } catch {
95
+ // Fall through to String below.
96
+ }
97
+ return cleanText(String(item)) || "(empty item)";
98
+ }
99
+
100
+ function summaryText(report, fallback) {
101
+ const summary = report?.summary;
102
+ if (typeof summary === "string" && summary.trim()) return cleanText(summary);
103
+ if (isRecord(summary)) {
104
+ const parts = [
105
+ summary.directAnswer,
106
+ summary.answer,
107
+ summary.summary,
108
+ summary.finding,
109
+ ]
110
+ .filter((value) => typeof value === "string" && value.trim())
111
+ .map(cleanText);
112
+ const confidence = cleanText(summary.confidence ?? "");
113
+ const caveat = cleanText(summary.keyCaveat ?? summary.caveat ?? "");
114
+ return (
115
+ [
116
+ parts[0],
117
+ confidence ? `Confidence: ${confidence}.` : undefined,
118
+ caveat ? `Key caveat: ${caveat}.` : undefined,
119
+ ]
120
+ .filter(Boolean)
121
+ .join(" ") || stringifyItem(summary)
122
+ );
123
+ }
124
+ return cleanText(fallback ?? "Research completed with audited evidence.");
125
+ }
126
+
127
+ function hasObjectSerializationArtifact(text) {
128
+ return /\[object Object\]/.test(String(text ?? ""));
129
+ }
130
+
42
131
  function truncateWords(text, maxWords) {
43
132
  const items = words(text);
44
133
  if (items.length <= maxWords) return cleanText(text);
@@ -56,88 +145,204 @@ function hostOf(url) {
56
145
  }
57
146
  }
58
147
 
59
- function collectUrls(value, urls = []) {
60
- if (typeof value === "string") {
61
- for (const match of value.matchAll(/https?:\/\/[^\s)\]}"`]+/g))
62
- urls.push(match[0].replace(/[.,;:]+$/, ""));
63
- return urls;
148
+ function normalizeUrl(url) {
149
+ if (typeof url !== "string") return null;
150
+ const trimmed = url.trim().replace(/[.,;:]+$/, "");
151
+ if (!/^https?:\/\//i.test(trimmed)) return null;
152
+ try {
153
+ const parsed = new URL(trimmed);
154
+ parsed.hash = "";
155
+ return parsed.toString();
156
+ } catch {
157
+ return trimmed;
64
158
  }
159
+ }
160
+
161
+ function collectStructuredUrls(value, urls = []) {
162
+ if (!value || typeof value !== "object") return urls;
65
163
  if (Array.isArray(value)) {
66
- for (const item of value) collectUrls(item, urls);
164
+ for (const item of value) collectStructuredUrls(item, urls);
67
165
  return urls;
68
166
  }
69
- if (value && typeof value === "object") {
70
- for (const item of Object.values(value)) collectUrls(item, urls);
167
+ for (const [key, item] of Object.entries(value)) {
168
+ if (
169
+ /^(sourceUrls?|evidenceUrls?|urls?|url|uri|href|links?|references?|refs?|basis|sources)$/i.test(
170
+ key,
171
+ )
172
+ ) {
173
+ for (const candidate of asArray(item).length ? item : [item]) {
174
+ const normalized = normalizeUrl(candidate);
175
+ if (normalized) urls.push(normalized);
176
+ else if (candidate && typeof candidate === "object") {
177
+ collectStructuredUrls(candidate, urls);
178
+ }
179
+ }
180
+ continue;
181
+ }
182
+ if (item && typeof item === "object") collectStructuredUrls(item, urls);
71
183
  }
72
184
  return urls;
73
185
  }
74
186
 
75
- function collectSourceLocators(value, locators = [], fieldName = "") {
76
- if (typeof value === "string") {
77
- const urls = collectUrls(value, []);
78
- if (urls.length > 0) locators.push(...urls);
79
- else if (
80
- /^(sourceUrls?|sourceRefs?|sourcePaths?|urls?|paths?)$/i.test(fieldName)
81
- ) {
82
- const trimmed = value.trim();
83
- if (trimmed) locators.push(trimmed);
187
+ function uniqueStructuredUrls(...values) {
188
+ const out = [];
189
+ const seen = new Set();
190
+ for (const value of values) {
191
+ for (const url of collectStructuredUrls(value, [])) {
192
+ if (seen.has(url)) continue;
193
+ seen.add(url);
194
+ out.push(url);
84
195
  }
85
- return locators;
86
196
  }
197
+ return out;
198
+ }
199
+
200
+ function urlsOf(item, limit = 3) {
201
+ return uniqueStructuredUrls(item).slice(0, limit);
202
+ }
203
+
204
+ function normalizeLocalRef(value) {
205
+ if (typeof value !== "string") return null;
206
+ const text = value.trim();
207
+ if (!text || /^https?:\/\//i.test(text) || isWorkflowSourceRefText(text))
208
+ return null;
209
+ const stripped = text.replace(/^(?:file|repo):/i, "");
210
+ if (!/[\w./-]+\.[\w]+(?:#L\d+(?:-L?\d+)?)?$/i.test(stripped)) return null;
211
+ return stripped;
212
+ }
213
+
214
+ function isWorkflowSourceRefText(value) {
215
+ return /^wsrc_[a-z0-9]{16,}$/i.test(String(value ?? "").trim());
216
+ }
217
+
218
+ function collectLocalRefs(value, refs = []) {
219
+ if (!value || typeof value !== "object") return refs;
87
220
  if (Array.isArray(value)) {
88
- for (const item of value) collectSourceLocators(item, locators, fieldName);
89
- return locators;
221
+ for (const item of value) collectLocalRefs(item, refs);
222
+ return refs;
90
223
  }
91
- if (value && typeof value === "object") {
92
- for (const [key, item] of Object.entries(value)) {
93
- collectSourceLocators(item, locators, key);
224
+ for (const [key, item] of Object.entries(value)) {
225
+ if (/^(files?|paths?|sourceRefs?|sourceUrls?|sources?)$/i.test(key)) {
226
+ for (const candidate of asArray(item).length ? item : [item]) {
227
+ const ref = normalizeLocalRef(candidate);
228
+ if (ref) refs.push(ref);
229
+ else if (candidate && typeof candidate === "object")
230
+ collectLocalRefs(candidate, refs);
231
+ }
232
+ continue;
94
233
  }
234
+ if (item && typeof item === "object") collectLocalRefs(item, refs);
95
235
  }
96
- return locators;
236
+ return refs;
97
237
  }
98
238
 
99
- function itemText(item, kind) {
100
- if (typeof item === "string") return cleanText(item);
101
- if (!item || typeof item !== "object") return "";
102
- const fields =
103
- kind === "recommendation"
104
- ? ["recommendation", "step", "action", "note", "finding", "gap"]
105
- : kind === "gap"
106
- ? ["gap", "note", "finding", "whyItMatters", "parentImpact"]
107
- : ["finding", "summary", "bestValue", "recommendation", "step", "note"];
239
+ function localRefsOf(item, limit = 3) {
240
+ const out = [];
241
+ const seen = new Set();
242
+ for (const ref of collectLocalRefs(item, [])) {
243
+ if (seen.has(ref)) continue;
244
+ seen.add(ref);
245
+ out.push(ref);
246
+ if (out.length >= limit) break;
247
+ }
248
+ return out;
249
+ }
250
+
251
+ function referenceList(item, limit = 3) {
252
+ const urls = markdownLinkList(urlsOf(item, limit), limit);
253
+ const localRefs = localRefsOf(item, limit)
254
+ .map((ref) => `\`${ref}\``)
255
+ .join(", ");
256
+ return [urls, localRefs].filter(Boolean).join("; ");
257
+ }
258
+
259
+ function markdownLinkList(urls, maxItems = 3) {
260
+ return urls
261
+ .slice(0, maxItems)
262
+ .map((url) => `[${hostOf(url)}](${url})`)
263
+ .join(", ");
264
+ }
265
+
266
+ function itemText(item, fields, fallback = "") {
267
+ if (typeof item === "string") return cleanText(item) || fallback;
268
+ if (!item || typeof item !== "object") return fallback;
108
269
  for (const field of fields) {
109
- if (typeof item[field] === "string" && item[field].trim())
270
+ if (typeof item[field] === "string" && item[field].trim()) {
110
271
  return cleanText(item[field]);
272
+ }
111
273
  }
112
- return cleanText(JSON.stringify(item));
274
+ return fallback;
113
275
  }
114
276
 
115
- function sourceSuffix(item, state, options) {
116
- const urls = [...new Set(collectUrls(item))].filter(Boolean);
117
- const selected = [];
118
- for (const url of urls) {
119
- if (state.urls.size >= options.maxUrls) break;
120
- if (state.urls.has(url)) continue;
121
- state.urls.add(url);
122
- selected.push(url);
123
- if (selected.length >= 1) break; // one URL per bullet keeps memo compact
124
- }
125
- if (selected.length === 0) return "";
126
- return ` (${selected.map((url) => hostOf(url)).join(", ")}: ${selected.join(" ")})`;
277
+ function evidenceStatusOf(item) {
278
+ if (!item || typeof item !== "object") return "not specified";
279
+ return cleanText(
280
+ item.evidenceStatus ??
281
+ item.status ??
282
+ item.confidence ??
283
+ item.sourceQuality ??
284
+ "not specified",
285
+ );
127
286
  }
128
287
 
129
- function bulletLines(items, kind, limit, state, options, perItemWords = 34) {
130
- const out = [];
131
- for (const item of asArray(items)) {
132
- if (out.length >= limit) break;
133
- const text = truncateWords(itemText(item, kind), perItemWords);
134
- if (!text) continue;
135
- out.push(`- ${text}${sourceSuffix(item, state, options)}`);
288
+ function confidenceOf(item) {
289
+ if (!item || typeof item !== "object") return "";
290
+ return cleanText(item.confidence ?? item.evidenceStatus ?? "");
291
+ }
292
+
293
+ function finiteNumber(value) {
294
+ const parsed = Number(value);
295
+ return Number.isFinite(parsed) ? parsed : undefined;
296
+ }
297
+
298
+ function normalizeClaimStatus(status) {
299
+ const text = cleanText(status).toLowerCase();
300
+ if (!text) return "";
301
+ if (text.includes("conflict")) return "conflicting";
302
+ if (text.includes("unsupported")) return "unsupported";
303
+ if (text.includes("partial")) return "partially_supported";
304
+ if (text.includes("verified")) return "verified";
305
+ return text;
306
+ }
307
+
308
+ function coverageCounts(coverage, fallback) {
309
+ if (!coverage || typeof coverage !== "object") return null;
310
+ const counts = {
311
+ total: finiteNumber(coverage.verificationCandidates) ?? fallback.total,
312
+ verified: finiteNumber(coverage.verified) ?? fallback.verified,
313
+ partially_supported:
314
+ finiteNumber(coverage.partiallySupported) ??
315
+ finiteNumber(coverage.partially_supported) ??
316
+ fallback.partially_supported,
317
+ unsupported: finiteNumber(coverage.unsupported) ?? fallback.unsupported,
318
+ conflicting: finiteNumber(coverage.conflicting) ?? fallback.conflicting,
319
+ };
320
+ if (counts.total == null) {
321
+ counts.total =
322
+ counts.verified +
323
+ counts.partially_supported +
324
+ counts.unsupported +
325
+ counts.conflicting;
136
326
  }
137
- return out;
327
+ return counts;
138
328
  }
139
329
 
140
- function claimCounts(control) {
330
+ function packetVerdictCounts(packet, fallback) {
331
+ const verdicts = packet?.verdictCounts;
332
+ if (!isRecord(verdicts)) return null;
333
+ const counts = coverageCounts(verdicts, fallback);
334
+ if (!counts) return null;
335
+ counts.total =
336
+ finiteNumber(packet?.invariantChecks?.candidateCount) ??
337
+ finiteNumber(verdicts.total) ??
338
+ counts.verified +
339
+ counts.partially_supported +
340
+ counts.unsupported +
341
+ counts.conflicting;
342
+ return counts;
343
+ }
344
+
345
+ function claimCounts(control, packet) {
141
346
  const claims = asArray(control?.claimVerdictIndex?.claims);
142
347
  const counts = {
143
348
  total: claims.length,
@@ -147,130 +352,861 @@ function claimCounts(control) {
147
352
  conflicting: 0,
148
353
  };
149
354
  for (const claim of claims) {
150
- const status = claim?.status;
355
+ const status = normalizeClaimStatus(claim?.status);
151
356
  if (status && Object.hasOwn(counts, status)) counts[status] += 1;
152
357
  }
153
- const coverage = control?.finalReport?.coverageSummary;
154
- if (coverage && typeof coverage === "object") {
155
- return {
156
- total:
157
- Number(coverage.verificationCandidates ?? counts.total) || counts.total,
158
- verified: Number(coverage.verified ?? counts.verified) || counts.verified,
159
- partially_supported:
160
- Number(coverage.partiallySupported ?? counts.partially_supported) ||
161
- counts.partially_supported,
162
- unsupported:
163
- Number(coverage.unsupported ?? counts.unsupported) ||
164
- counts.unsupported,
165
- conflicting:
166
- Number(coverage.conflicting ?? counts.conflicting) ||
167
- counts.conflicting,
168
- };
358
+ const packetCounts = packetVerdictCounts(packet, counts);
359
+ if (packetCounts) return packetCounts;
360
+
361
+ const coverage = coverageCounts(
362
+ control?.finalReport?.coverageSummary,
363
+ counts,
364
+ );
365
+ if (claims.length === 0 && coverage) return coverage;
366
+ if (!coverage) return counts;
367
+
368
+ const mismatches = [];
369
+ for (const key of [
370
+ "total",
371
+ "verified",
372
+ "partially_supported",
373
+ "unsupported",
374
+ "conflicting",
375
+ ]) {
376
+ if (coverage[key] !== counts[key]) {
377
+ mismatches.push({
378
+ field: key,
379
+ claimVerdictIndex: counts[key],
380
+ coverageSummary: coverage[key],
381
+ });
382
+ }
169
383
  }
170
- return counts;
384
+ return mismatches.length > 0
385
+ ? { ...counts, coverageSummaryMismatch: mismatches }
386
+ : counts;
171
387
  }
172
388
 
173
- function renderExecutiveMarkdown(control, options) {
174
- const report = control?.finalReport ?? {};
175
- const state = { urls: new Set() };
176
- const maxWords = options.maxWords;
177
- const counts = claimCounts(control);
178
- const factSlots = asArray(report.factSlotCoverage);
179
- const filledSlots = factSlots.filter(
180
- (slot) => slot?.status === "filled",
181
- ).length;
182
- const partialSlots = factSlots.filter(
183
- (slot) => slot?.status === "partial",
184
- ).length;
185
- const missingSlots = factSlots.filter((slot) =>
186
- ["missing", "gap", "conflicting"].includes(slot?.status),
187
- ).length;
188
-
189
- const sections = [];
190
- sections.push("# Executive summary");
191
- sections.push("");
192
- sections.push(
193
- `**Bottom line:** ${truncateWords(report.summary ?? control.digest ?? "Research completed with audited evidence.", 85)}`,
389
+ function factSlotSummary(factSlots) {
390
+ return {
391
+ total: factSlots.length,
392
+ filled: factSlots.filter((slot) => slot?.status === "filled").length,
393
+ partial: factSlots.filter((slot) => slot?.status === "partial").length,
394
+ missingOrConflicting: factSlots.filter((slot) =>
395
+ ["missing", "gap", "conflicting"].includes(slot?.status),
396
+ ).length,
397
+ };
398
+ }
399
+
400
+ function stringArray(value, limit = Infinity) {
401
+ const out = [];
402
+ const seen = new Set();
403
+ for (const item of asArray(value)) {
404
+ if (typeof item !== "string") continue;
405
+ const text = item.trim();
406
+ if (!text || seen.has(text)) continue;
407
+ seen.add(text);
408
+ out.push(text);
409
+ if (out.length >= limit) break;
410
+ }
411
+ return out;
412
+ }
413
+
414
+ function uniqueStrings(values, limit = Infinity) {
415
+ const out = [];
416
+ const seen = new Set();
417
+ for (const value of values) {
418
+ if (typeof value !== "string") continue;
419
+ const text = value.trim();
420
+ if (!text || seen.has(text)) continue;
421
+ seen.add(text);
422
+ out.push(text);
423
+ if (out.length >= limit) break;
424
+ }
425
+ return out;
426
+ }
427
+
428
+ function packetOf(packetSource) {
429
+ return isRecord(packetSource?.packet) ? packetSource.packet : {};
430
+ }
431
+
432
+ function claimIdOf(row) {
433
+ return cleanText(row?.id ?? row?.claimId ?? "");
434
+ }
435
+
436
+ function gapIdOf(row) {
437
+ return cleanText(row?.id ?? row?.gapId ?? "");
438
+ }
439
+
440
+ function claimLedger(packet, control) {
441
+ const packetLedger = asArray(packet?.claimVerdictLedger);
442
+ return packetLedger.length
443
+ ? packetLedger
444
+ : asArray(control?.claimVerdictIndex?.claims);
445
+ }
446
+
447
+ function mapById(rows, idFn) {
448
+ const out = new Map();
449
+ for (const row of rows) {
450
+ const id = idFn(row);
451
+ if (id && !out.has(id)) out.set(id, row);
452
+ }
453
+ return out;
454
+ }
455
+
456
+ function numberedId(prefix, index) {
457
+ return `${prefix}-${String(index + 1).padStart(3, "0")}`;
458
+ }
459
+
460
+ function packetGapRows(packet) {
461
+ const remaining = asArray(packet?.remainingGaps).map((gap, index) => ({
462
+ id: gapIdOf(gap) || numberedId("gap-remaining", index),
463
+ kind: "Gap",
464
+ ...gap,
465
+ }));
466
+ const coverage = asArray(packet?.coverageGaps).map((gap, index) => ({
467
+ id: gapIdOf(gap) || numberedId("gap-coverage", index),
468
+ kind: "Coverage gap",
469
+ ...gap,
470
+ }));
471
+ return [...remaining, ...coverage];
472
+ }
473
+
474
+ function rowsForIds(ids, rowById, warnings, label) {
475
+ const rows = [];
476
+ for (const id of ids) {
477
+ const row = rowById.get(id);
478
+ if (row) {
479
+ rows.push(row);
480
+ continue;
481
+ }
482
+ warnings.push({
483
+ section: "references",
484
+ label,
485
+ total: 1,
486
+ rendered: 0,
487
+ missingId: id,
488
+ });
489
+ }
490
+ return rows;
491
+ }
492
+
493
+ function claimSourceUrls(rows, limit = 8) {
494
+ return uniqueStrings(
495
+ rows.flatMap((row) => [...asArray(row?.sourceUrls), ...urlsOf(row, limit)]),
496
+ limit,
194
497
  );
195
- sections.push("");
498
+ }
196
499
 
197
- const findings = bulletLines(
198
- report.mainFindings,
199
- "finding",
200
- options.maxFindings,
201
- state,
202
- options,
500
+ function claimSourceRefs(rows, limit = 8) {
501
+ return uniqueStrings(
502
+ rows.flatMap((row) => asArray(row?.sourceRefs)),
503
+ limit,
203
504
  );
204
- if (findings.length) {
205
- sections.push("**Top findings**");
206
- sections.push(...findings);
207
- sections.push("");
505
+ }
506
+
507
+ function evidenceStrength(status) {
508
+ switch (normalizeClaimStatus(status)) {
509
+ case "verified":
510
+ return 3;
511
+ case "partially_supported":
512
+ return 2;
513
+ case "unsupported":
514
+ return 1;
515
+ case "conflicting":
516
+ return 0;
517
+ default:
518
+ return -1;
208
519
  }
520
+ }
209
521
 
210
- const recommendations = bulletLines(
211
- report.recommendations?.length ? report.recommendations : report.actionPlan,
212
- "recommendation",
213
- options.maxRecommendations,
214
- state,
215
- options,
216
- 32,
217
- );
218
- if (recommendations.length) {
219
- sections.push("**Recommended next steps**");
220
- sections.push(...recommendations);
221
- sections.push("");
522
+ function evidenceStatusFromRows(rows, fallback) {
523
+ if (rows.length === 0) return cleanText(fallback) || "not specified";
524
+ let weakest = "verified";
525
+ let weakestScore = Infinity;
526
+ for (const row of rows) {
527
+ const status = normalizeClaimStatus(row?.status ?? row?.verdict);
528
+ const score = evidenceStrength(status);
529
+ if (score >= 0 && score < weakestScore) {
530
+ weakest = status;
531
+ weakestScore = score;
532
+ }
533
+ }
534
+ return weakestScore === Infinity
535
+ ? cleanText(fallback) || "not specified"
536
+ : weakest;
537
+ }
538
+
539
+ function claimToFinding(row) {
540
+ return {
541
+ id: claimIdOf(row),
542
+ finding: cleanText(row?.claim ?? row?.support ?? stringifyItem(row)),
543
+ evidenceStatus: normalizeClaimStatus(row?.status) || row?.status,
544
+ confidence: row?.confidence,
545
+ sourceUrls: asArray(row?.sourceUrls),
546
+ sourceRefs: asArray(row?.sourceRefs),
547
+ rationale: row?.support,
548
+ caveat: row?.caveat,
549
+ correctionOrCounterclaim: row?.correctionOrCounterclaim,
550
+ };
551
+ }
552
+
553
+ function supportingClaimIds(item) {
554
+ return uniqueStrings([
555
+ ...asArray(item?.supportingClaimIds),
556
+ ...asArray(item?.claimIds),
557
+ ...asArray(item?.relatedClaimIds),
558
+ ]);
559
+ }
560
+
561
+ function withSupportingEvidence(item, claimRows) {
562
+ return {
563
+ ...item,
564
+ evidenceStatus: evidenceStatusFromRows(claimRows, item?.evidenceStatus),
565
+ sourceUrls: uniqueStrings(
566
+ [...asArray(item?.sourceUrls), ...claimSourceUrls(claimRows)],
567
+ 8,
568
+ ),
569
+ sourceRefs: uniqueStrings(
570
+ [...asArray(item?.sourceRefs), ...claimSourceRefs(claimRows)],
571
+ 8,
572
+ ),
573
+ };
574
+ }
575
+
576
+ function coverageSummaryFromPacket(packet, fallback = {}) {
577
+ const counts = packetVerdictCounts(packet, {
578
+ total: 0,
579
+ verified: 0,
580
+ partially_supported: 0,
581
+ unsupported: 0,
582
+ conflicting: 0,
583
+ });
584
+ if (!counts) return fallback;
585
+ return {
586
+ ...fallback,
587
+ verified: counts.verified,
588
+ partiallySupported: counts.partially_supported,
589
+ unsupported: counts.unsupported,
590
+ conflicting: counts.conflicting,
591
+ verificationCandidates: counts.total,
592
+ depth: packet?.researchMetadataSeed?.depth ?? fallback.depth,
593
+ researchQuestions:
594
+ packet?.researchMetadataSeed?.researchQuestions ??
595
+ fallback.researchQuestions,
596
+ preserved:
597
+ packet?.overflowLedger?.preservedClaimCount ?? fallback.preserved,
598
+ coverageGaps:
599
+ packet?.overflowLedger?.coverageGapCount ?? fallback.coverageGaps,
600
+ };
601
+ }
602
+
603
+ function composeResearchReport(control, packetSource) {
604
+ const packet = packetOf(packetSource);
605
+ const legacyReport = control?.finalReport ?? {};
606
+ const synthesis = isRecord(control?.synthesis) ? control.synthesis : null;
607
+ const ledger = claimLedger(packet, control);
608
+ const claimById = mapById(ledger, claimIdOf);
609
+ const gapRows = packetGapRows(packet);
610
+ const gapById = mapById(gapRows, gapIdOf);
611
+ const warnings = [];
612
+
613
+ if (!synthesis) {
614
+ const report = { ...legacyReport };
615
+ if (asArray(packet.factSlotCoverage).length > 0)
616
+ report.factSlotCoverage = packet.factSlotCoverage;
617
+ if (isRecord(packet.researchMetadataSeed))
618
+ report.researchMetadata = packet.researchMetadataSeed;
619
+ if (isRecord(packet.verdictCounts))
620
+ report.coverageSummary = coverageSummaryFromPacket(
621
+ packet,
622
+ report.coverageSummary,
623
+ );
624
+ if (asArray(report.remainingGaps).length === 0 && gapRows.length > 0)
625
+ report.remainingGaps = gapRows;
626
+ if (
627
+ asArray(report.researchScopeCoverage).length === 0 &&
628
+ asArray(packet.researchScopeCoverage).length > 0
629
+ ) {
630
+ report.researchScopeCoverage = packet.researchScopeCoverage;
631
+ }
632
+ return { report, packet, ledger, warnings };
633
+ }
634
+
635
+ const keyFindingIds = stringArray(synthesis.keyFindingIds, 12);
636
+ const keyFindingRows = keyFindingIds.length
637
+ ? rowsForIds(keyFindingIds, claimById, warnings, "key findings")
638
+ : ledger
639
+ .filter((row) => normalizeClaimStatus(row?.status) === "verified")
640
+ .slice(0, 8);
641
+ const mapOverlayItems = (items, textField) =>
642
+ asArray(items).map((item) => {
643
+ const ids = supportingClaimIds(item);
644
+ const rows = rowsForIds(ids, claimById, warnings, textField);
645
+ return withSupportingEvidence(item, rows);
646
+ });
647
+ const caveatNotes = asArray(synthesis.caveatNotes).map((item) => {
648
+ const rows = rowsForIds(
649
+ supportingClaimIds(item),
650
+ claimById,
651
+ warnings,
652
+ "caveat notes",
653
+ );
654
+ const gaps = rowsForIds(
655
+ stringArray(item?.gapIds, 12),
656
+ gapById,
657
+ warnings,
658
+ "gap notes",
659
+ );
660
+ return withSupportingEvidence(
661
+ {
662
+ ...item,
663
+ relatedGaps: gaps,
664
+ },
665
+ rows,
666
+ );
667
+ });
668
+ const optionalUnsupported = rowsForIds(
669
+ stringArray(synthesis.notableUnsupportedClaimIds, 12),
670
+ claimById,
671
+ warnings,
672
+ "unsupported claims",
673
+ ).map(claimToFinding);
674
+ const optionalContested = rowsForIds(
675
+ stringArray(synthesis.contestedClaimIds, 12),
676
+ claimById,
677
+ warnings,
678
+ "contested claims",
679
+ ).map(claimToFinding);
680
+ const derivedUnsupported = ledger
681
+ .filter((row) => normalizeClaimStatus(row?.status) === "unsupported")
682
+ .map(claimToFinding);
683
+ const derivedContested = ledger
684
+ .filter((row) => normalizeClaimStatus(row?.status) === "conflicting")
685
+ .map(claimToFinding);
686
+
687
+ return {
688
+ report: {
689
+ summary: synthesis.bottomLine ?? control?.digest,
690
+ researchMetadata: packet.researchMetadataSeed ?? {},
691
+ coverageSummary: coverageSummaryFromPacket(packet, {}),
692
+ factSlotCoverage: asArray(packet.factSlotCoverage),
693
+ mainFindings: keyFindingRows.map(claimToFinding),
694
+ recommendations: mapOverlayItems(
695
+ synthesis.recommendations,
696
+ "recommendations",
697
+ ),
698
+ actionPlan: mapOverlayItems(synthesis.actionPlan, "action plan"),
699
+ caveatedFindings: caveatNotes,
700
+ contestedAreas: optionalContested.length
701
+ ? optionalContested
702
+ : derivedContested,
703
+ notableUnsupportedClaims: optionalUnsupported.length
704
+ ? optionalUnsupported
705
+ : derivedUnsupported,
706
+ unverifiedButRelevant: asArray(packet.preservedClaims),
707
+ parentDecisionNotes: mapOverlayItems(
708
+ synthesis.parentDecisionNotes,
709
+ "decision notes",
710
+ ),
711
+ researchScopeCoverage: asArray(packet.researchScopeCoverage),
712
+ remainingGaps: gapRows,
713
+ },
714
+ packet,
715
+ ledger,
716
+ warnings,
717
+ };
718
+ }
719
+
720
+ function statusRank(item) {
721
+ const status =
722
+ `${item?.evidenceStatus ?? item?.status ?? item?.confidence ?? ""}`.toLowerCase();
723
+ if (
724
+ status.includes("missing") ||
725
+ status.includes("gap") ||
726
+ status.includes("conflict")
727
+ ) {
728
+ return 0;
222
729
  }
730
+ if (status.includes("unsupported")) return 1;
731
+ if (status.includes("partial")) return 2;
732
+ if (status.includes("verified") && !status.includes("partial")) return 3;
733
+ if (status.includes("filled") || status.includes("high")) return 4;
734
+ return 5;
735
+ }
223
736
 
224
- const caveatItems = [
225
- ...asArray(report.caveatedFindings),
226
- ...asArray(report.remainingGaps),
227
- ...asArray(report.parentDecisionNotes),
737
+ function sortedFactSlots(report) {
738
+ return asArray(report.factSlotCoverage)
739
+ .slice()
740
+ .sort(
741
+ (a, b) =>
742
+ statusRank(a) - statusRank(b) ||
743
+ cleanText(a?.slotId ?? a?.label).localeCompare(
744
+ cleanText(b?.slotId ?? b?.label),
745
+ ),
746
+ );
747
+ }
748
+
749
+ function renderEvidenceStrength(report) {
750
+ const slots = sortedFactSlots(report);
751
+ const rows = slots.map((slot) => {
752
+ const area = escapeTableCell(
753
+ slot.label ?? slot.slotId ?? slot.bestValue ?? "Evidence area",
754
+ );
755
+ const status = escapeTableCell(evidenceStatusOf(slot));
756
+ const evidence = escapeTableCell(referenceList(slot, 2) || "—");
757
+ const impact = escapeTableCell(
758
+ slot.parentImpact ?? slot.whyItMatters ?? slot.notes ?? "",
759
+ );
760
+ return `| ${area || "Evidence area"} | ${status || "—"} | ${evidence} | ${impact || "—"} |`;
761
+ });
762
+ if (rows.length === 0) return [];
763
+ return [
764
+ "## Evidence strength",
765
+ "",
766
+ "| Area | Status | Evidence | Why it matters |",
767
+ "|---|---|---|---|",
768
+ ...rows,
769
+ "",
228
770
  ];
229
- const gaps = bulletLines(
230
- caveatItems,
231
- "gap",
232
- options.maxGaps,
233
- state,
234
- options,
235
- 30,
771
+ }
772
+
773
+ function mainFindingEntries(report) {
774
+ return asArray(report.mainFindings).map((item) => ({
775
+ item,
776
+ text: itemText(
777
+ item,
778
+ ["finding", "summary", "bestValue", "claim"],
779
+ stringifyItem(item),
780
+ ),
781
+ }));
782
+ }
783
+
784
+ function recommendationEntries(report) {
785
+ return asArray(report.recommendations).map((item) => ({
786
+ item,
787
+ text: itemText(
788
+ item,
789
+ ["recommendation", "action", "step", "note"],
790
+ stringifyItem(item),
791
+ ),
792
+ }));
793
+ }
794
+
795
+ function actionEntries(report) {
796
+ return asArray(report.actionPlan).map((item) => ({
797
+ item,
798
+ text:
799
+ itemText(item, ["action", "recommendation", "note"]) ||
800
+ (typeof item?.step === "string" && cleanText(item.step)) ||
801
+ stringifyItem(item),
802
+ }));
803
+ }
804
+
805
+ function renderMainFindings(report) {
806
+ const findings = mainFindingEntries(report);
807
+ if (findings.length === 0) return [];
808
+ const out = ["## Main findings", ""];
809
+ findings.forEach(({ item: finding, text }, index) => {
810
+ const status = evidenceStatusOf(finding);
811
+ const confidence = confidenceOf(finding);
812
+ const urls = referenceList(finding, 4);
813
+ out.push(`### ${index + 1}. ${text}`);
814
+ out.push("");
815
+ out.push(
816
+ `Evidence status: **${status || "not specified"}**${confidence && confidence !== status ? ` \nConfidence: **${confidence}**` : ""}`,
817
+ );
818
+ if (urls) out.push(`Sources: ${urls}`);
819
+ const explanation = itemText(finding, [
820
+ "rationale",
821
+ "explanation",
822
+ "details",
823
+ "notes",
824
+ ]);
825
+ if (explanation && explanation !== text) out.push("", explanation);
826
+ out.push("");
827
+ });
828
+ return out;
829
+ }
830
+
831
+ function renderRecommendations(report) {
832
+ const recommendations = recommendationEntries(report);
833
+ if (recommendations.length === 0) return [];
834
+ const out = ["## Recommendations", ""];
835
+ recommendations.forEach(({ item, text }, index) => {
836
+ const status = evidenceStatusOf(item);
837
+ const urls = referenceList(item, 4);
838
+ out.push(`${index + 1}. **${text}**`);
839
+ out.push(` - Evidence status: ${status || "not specified"}`);
840
+ if (urls) out.push(` - Sources: ${urls}`);
841
+ out.push("");
842
+ });
843
+ return out;
844
+ }
845
+
846
+ function renderActionPlan(report) {
847
+ const actions = actionEntries(report);
848
+ if (actions.length === 0) return [];
849
+ const out = ["## Action plan", ""];
850
+ actions.forEach(({ item, text }, index) => {
851
+ const numericStep = Number(item?.step);
852
+ const step = Number.isFinite(numericStep) ? numericStep : index + 1;
853
+ const urls = referenceList(item, 3);
854
+ const evidence = evidenceStatusOf(item);
855
+ out.push(`${step}. ${text}`);
856
+ if (evidence && evidence !== "not specified")
857
+ out.push(` - Evidence: ${evidence}`);
858
+ if (urls) out.push(` - Sources: ${urls}`);
859
+ out.push("");
860
+ });
861
+ return out;
862
+ }
863
+
864
+ function fallbackCaveatText(item) {
865
+ if (!isRecord(item)) return stringifyItem(item);
866
+ const id = cleanText(item.id ?? item.gapId ?? "");
867
+ const slotIds = uniqueStrings([
868
+ item.slotId,
869
+ ...asArray(item.relatedFactSlotIds),
870
+ ]).join(", ");
871
+ const kind = cleanText(item.kind ?? "gap");
872
+ return (
873
+ [kind, id, slotIds ? `related slots: ${slotIds}` : undefined]
874
+ .filter(Boolean)
875
+ .join(" — ") || stringifyItem(item)
876
+ );
877
+ }
878
+
879
+ function caveatText(item) {
880
+ return itemText(
881
+ item,
882
+ [
883
+ "gap",
884
+ "finding",
885
+ "claim",
886
+ "note",
887
+ "reason",
888
+ "nextStep",
889
+ "evidenceState",
890
+ "whyItMatters",
891
+ "parentImpact",
892
+ ],
893
+ fallbackCaveatText(item),
236
894
  );
237
- if (gaps.length) {
238
- sections.push("**Key caveats / gaps**");
239
- sections.push(...gaps);
240
- sections.push("");
895
+ }
896
+
897
+ function caveatCategories(report) {
898
+ return [
899
+ { kind: "Gap", items: flattenItems(report.remainingGaps) },
900
+ {
901
+ kind: "Unsupported",
902
+ items: flattenItems(report.notableUnsupportedClaims),
903
+ },
904
+ { kind: "Contested", items: flattenItems(report.contestedAreas) },
905
+ { kind: "Caveat", items: flattenItems(report.caveatedFindings) },
906
+ {
907
+ kind: "Unverified lead",
908
+ items: flattenItems(report.unverifiedButRelevant),
909
+ },
910
+ { kind: "Decision note", items: flattenItems(report.parentDecisionNotes) },
911
+ ]
912
+ .map((category) => ({
913
+ kind: category.kind,
914
+ entries: category.items
915
+ .map((item) => ({ item, text: caveatText(item) }))
916
+ .filter((entry) => entry.text),
917
+ }))
918
+ .filter((category) => category.entries.length > 0);
919
+ }
920
+
921
+ function selectCaveats(report) {
922
+ const categories = caveatCategories(report);
923
+ const selected = [];
924
+ for (const category of categories) {
925
+ for (const entry of category.entries) {
926
+ selected.push({ kind: category.kind, ...entry });
927
+ }
928
+ }
929
+ return {
930
+ selected,
931
+ total: selected.length,
932
+ };
933
+ }
934
+
935
+ function renderCaveats(report) {
936
+ const selection = selectCaveats(report);
937
+ if (selection.total === 0) return [];
938
+ const out = ["## Caveats and remaining gaps", ""];
939
+ for (const { kind, item, text } of selection.selected) {
940
+ const urls = referenceList(item, 3);
941
+ out.push(`- **${kind}:** ${text}${urls ? ` (${urls})` : ""}`);
942
+ }
943
+ out.push("");
944
+ return out;
945
+ }
946
+
947
+ function renderSourceIndex(sourceIndex) {
948
+ if (sourceIndex.length === 0) return [];
949
+ const grouped = new Map();
950
+ for (const url of sourceIndex) {
951
+ const host = hostOf(url);
952
+ if (!grouped.has(host)) grouped.set(host, []);
953
+ grouped.get(host).push(url);
954
+ }
955
+ const out = ["## Source index", ""];
956
+ for (const [host, urls] of grouped) {
957
+ out.push(
958
+ `- **${host}**: ${urls.map((url) => `[${url}](${url})`).join(", ")}`,
959
+ );
241
960
  }
961
+ out.push("");
962
+ return out;
963
+ }
964
+
965
+ function renderAuditSummary(report, claimSummary, slots) {
966
+ const coverage = report?.coverageSummary ?? {};
967
+ const mismatches = asArray(claimSummary.coverageSummaryMismatch);
968
+ return [
969
+ "## Audit summary",
970
+ "",
971
+ `- Claims: ${claimSummary.verified} verified, ${claimSummary.partially_supported} partially supported, ${claimSummary.unsupported} unsupported, ${claimSummary.conflicting} conflicting.`,
972
+ `- Fact slots: ${slots.filled} filled, ${slots.partial} partial, ${slots.missingOrConflicting} missing/conflicting, ${slots.total} total.`,
973
+ ...(mismatches.length > 0
974
+ ? [
975
+ `- Coverage summary mismatch: displayed claim counts come from \`claimVerdictIndex\`; model coverageSummary disagreed on ${mismatches
976
+ .map((mismatch) => mismatch.field)
977
+ .join(", ")}.`,
978
+ ]
979
+ : []),
980
+ ...(coverage.researchQuestions != null
981
+ ? [`- Research questions: ${coverage.researchQuestions}.`]
982
+ : []),
983
+ "- Audit artifact: `audit.md`.",
984
+ "",
985
+ ];
986
+ }
987
+
988
+ function renderWarnings(sectionCounts) {
989
+ const checks = [
990
+ ["findings", "renderedFindings", "findings"],
991
+ ["recommendations", "renderedRecommendations", "recommendations"],
992
+ ["actionItems", "renderedActionItems", "action items"],
993
+ ["caveatsAndGaps", "renderedCaveatsAndGaps", "caveats/gaps"],
994
+ ["factSlots", "renderedFactSlots", "fact slots"],
995
+ ["sourceUrls", "renderedSourceUrls", "source URLs"],
996
+ ];
997
+ return checks
998
+ .filter(([totalKey, renderedKey]) => {
999
+ const total = Number(sectionCounts[totalKey] ?? 0);
1000
+ const rendered = Number(sectionCounts[renderedKey] ?? 0);
1001
+ return total !== rendered;
1002
+ })
1003
+ .map(([totalKey, renderedKey, label]) => ({
1004
+ section: totalKey,
1005
+ label,
1006
+ total: sectionCounts[totalKey],
1007
+ rendered: sectionCounts[renderedKey],
1008
+ }));
1009
+ }
242
1010
 
243
- sections.push(
244
- `**Audit trail:** Full evidence remains in \`final-audit.control.json\`: ${counts.verified} verified, ${counts.partially_supported} partially supported, ${counts.unsupported} unsupported, ${counts.conflicting} conflicting claims; fact slots ${filledSlots} filled, ${partialSlots} partial, ${missingSlots} missing/conflicting.`,
1011
+ function renderResearchMarkdown(control, packetSource, options = {}) {
1012
+ const composed = composeResearchReport(control, packetSource);
1013
+ const report = composed.report;
1014
+ const claimSummary = claimCounts(control, composed.packet);
1015
+ const factSlots = sortedFactSlots(report);
1016
+ const slots = factSlotSummary(asArray(report.factSlotCoverage));
1017
+ const findings = mainFindingEntries(report);
1018
+ const recommendations = recommendationEntries(report);
1019
+ const actions = actionEntries(report);
1020
+ const caveats = selectCaveats(report);
1021
+ const allSourceIndex = uniqueStructuredUrls(
1022
+ report.factSlotCoverage,
1023
+ report.mainFindings,
1024
+ report.recommendations,
1025
+ report.actionPlan,
1026
+ report.caveatedFindings,
1027
+ report.contestedAreas,
1028
+ report.notableUnsupportedClaims,
1029
+ report.remainingGaps,
1030
+ report.parentDecisionNotes,
1031
+ report.unverifiedButRelevant,
1032
+ composed.ledger,
245
1033
  );
1034
+ const maxUrls = Number.isFinite(Number(options.maxUrls))
1035
+ ? Math.max(0, Number(options.maxUrls))
1036
+ : Infinity;
1037
+ const sourceIndex = Number.isFinite(maxUrls)
1038
+ ? allSourceIndex.slice(0, maxUrls)
1039
+ : allSourceIndex;
1040
+ const sectionCounts = {
1041
+ findings: asArray(report.mainFindings).length,
1042
+ renderedFindings: findings.length,
1043
+ recommendations: asArray(report.recommendations).length,
1044
+ renderedRecommendations: recommendations.length,
1045
+ actionItems: asArray(report.actionPlan).length,
1046
+ renderedActionItems: actions.length,
1047
+ caveatsAndGaps:
1048
+ flattenItems(report.remainingGaps).length +
1049
+ flattenItems(report.notableUnsupportedClaims).length +
1050
+ flattenItems(report.contestedAreas).length +
1051
+ flattenItems(report.caveatedFindings).length +
1052
+ flattenItems(report.unverifiedButRelevant).length +
1053
+ flattenItems(report.parentDecisionNotes).length,
1054
+ renderedCaveatsAndGaps: caveats.selected.length,
1055
+ factSlots: asArray(report.factSlotCoverage).length,
1056
+ renderedFactSlots: factSlots.length,
1057
+ sourceUrls: allSourceIndex.length,
1058
+ renderedSourceUrls: sourceIndex.length,
1059
+ };
1060
+ const warnings = [...renderWarnings(sectionCounts), ...composed.warnings];
1061
+
1062
+ const sections = [
1063
+ "# Research report",
1064
+ "",
1065
+ "## Bottom line",
1066
+ "",
1067
+ summaryText(report, control.digest),
1068
+ "",
1069
+ ...renderEvidenceStrength(report),
1070
+ ...renderMainFindings(report),
1071
+ ...renderRecommendations(report),
1072
+ ...renderActionPlan(report),
1073
+ ...renderCaveats(report),
1074
+ ...renderSourceIndex(sourceIndex),
1075
+ ...renderAuditSummary(report, claimSummary, slots),
1076
+ ];
246
1077
 
247
- let markdown = sections
1078
+ const markdown = sections
248
1079
  .join("\n")
249
1080
  .replace(/\n{3,}/g, "\n\n")
250
1081
  .trim();
251
- let truncated = false;
252
- if (countWords(markdown) > maxWords) {
253
- truncated = true;
254
- markdown = truncateWords(markdown, maxWords);
255
- }
256
- for (const locator of [...new Set(collectSourceLocators(control))]) {
257
- if (state.urls.size >= options.maxUrls) break;
258
- state.urls.add(locator);
259
- }
260
1082
  return {
261
1083
  markdown,
262
- truncated,
263
- sourceUrls: [...state.urls],
264
- counts,
265
- factSlots: {
266
- total: factSlots.length,
267
- filled: filledSlots,
268
- partial: partialSlots,
269
- missingOrConflicting: missingSlots,
270
- },
1084
+ sourceIndex,
1085
+ allSourceIndex,
1086
+ claimSummary,
1087
+ factSlotSummary: slots,
1088
+ sectionCounts,
1089
+ renderWarnings: warnings,
271
1090
  };
272
1091
  }
273
1092
 
1093
+ function stripLeadingHeading(markdown) {
1094
+ return String(markdown ?? "").replace(/^#\s+[^\n]+\n*/i, "");
1095
+ }
1096
+
1097
+ function synthesisClaimRows(control, rows) {
1098
+ const synthesis = isRecord(control?.synthesis) ? control.synthesis : null;
1099
+ if (!synthesis) return asArray(control?.claimVerdictIndex?.claims);
1100
+ const rowById = mapById(rows, claimIdOf);
1101
+ const ids = uniqueStrings([
1102
+ ...asArray(synthesis.keyFindingIds),
1103
+ ...asArray(synthesis.notableUnsupportedClaimIds),
1104
+ ...asArray(synthesis.contestedClaimIds),
1105
+ ...asArray(synthesis.recommendations).flatMap(supportingClaimIds),
1106
+ ...asArray(synthesis.actionPlan).flatMap(supportingClaimIds),
1107
+ ...asArray(synthesis.caveatNotes).flatMap(supportingClaimIds),
1108
+ ...asArray(synthesis.parentDecisionNotes).flatMap(supportingClaimIds),
1109
+ ]);
1110
+ return ids.map((id) => rowById.get(id)).filter(Boolean);
1111
+ }
1112
+
1113
+ function renderAuditMarkdown(control, packetSource, rendered) {
1114
+ const packet = packetSource?.packet ?? {};
1115
+ const report = control?.finalReport ?? {};
1116
+ const ledger = asArray(packet.claimVerdictLedger);
1117
+ const claims = synthesisClaimRows(control, ledger);
1118
+ const gaps = asArray(packet.remainingGaps).length
1119
+ ? asArray(packet.remainingGaps)
1120
+ : asArray(report.remainingGaps);
1121
+ const sourceRefJoinFailures = asArray(packet.sourceRefJoinFailures).filter(
1122
+ (failure) => uniqueStructuredUrls(failure).length > 0,
1123
+ );
1124
+ const factSlots = asArray(packet.factSlotCoverage).length
1125
+ ? asArray(packet.factSlotCoverage)
1126
+ : asArray(report.factSlotCoverage);
1127
+ const rows = ledger.length ? ledger : claims;
1128
+ const out = [
1129
+ "# Research audit",
1130
+ "",
1131
+ "This artifact preserves the detailed claim/gap/source ledger behind `executive.md`.",
1132
+ "",
1133
+ "## Claim verdict ledger",
1134
+ "",
1135
+ ];
1136
+ if (rows.length > 0) {
1137
+ out.push(
1138
+ "| ID | Status | Claim/support | Caveat/source |",
1139
+ "|---|---|---|---|",
1140
+ );
1141
+ for (const row of rows) {
1142
+ const id = escapeTableCell(row.id ?? row.claimId ?? "—");
1143
+ const status = escapeTableCell(row.status ?? row.confidence ?? "—");
1144
+ const support = escapeTableCell(
1145
+ row.claim ??
1146
+ row.support ??
1147
+ row.verdictDigest?.support ??
1148
+ stringifyItem(row),
1149
+ );
1150
+ const caveat = escapeTableCell(
1151
+ row.caveat ??
1152
+ row.correctionOrCounterclaim ??
1153
+ markdownLinkList(urlsOf(row, 3), 3) ??
1154
+ "—",
1155
+ );
1156
+ out.push(`| ${id} | ${status} | ${support} | ${caveat || "—"} |`);
1157
+ }
1158
+ } else {
1159
+ out.push("No compact claim ledger was provided.");
1160
+ }
1161
+ out.push("", "## Fact slot coverage", "");
1162
+ if (factSlots.length > 0) {
1163
+ out.push(
1164
+ "| Slot | Status | Best value | Gap/impact |",
1165
+ "|---|---|---|---|",
1166
+ );
1167
+ for (const slot of factSlots) {
1168
+ out.push(
1169
+ `| ${escapeTableCell(slot.slotId ?? slot.label ?? "—")} | ${escapeTableCell(slot.status ?? "—")} | ${escapeTableCell(isRecord(slot.bestValue) ? stringifyItem(slot.bestValue) : (slot.bestValue ?? "—"))} | ${escapeTableCell(slot.gapReason || slot.parentImpact || "—")} |`,
1170
+ );
1171
+ }
1172
+ } else {
1173
+ out.push("No fact-slot ledger was provided.");
1174
+ }
1175
+ out.push("", "## Remaining gaps", "");
1176
+ if (gaps.length > 0) {
1177
+ for (const gap of gaps)
1178
+ out.push(`- ${caveatText(gap) || stringifyItem(gap)}`);
1179
+ } else {
1180
+ out.push("No remaining gaps were reported.");
1181
+ }
1182
+ if (claims.length > 0 && ledger.length > 0) {
1183
+ out.push("", "## Claims used in executive synthesis", "");
1184
+ for (const claim of claims) {
1185
+ out.push(
1186
+ `- **${cleanText(claim.id ?? "claim")}** (${cleanText(claim.status ?? "unknown")}): ${cleanText(claim.claim ?? claim.support ?? stringifyItem(claim))}`,
1187
+ );
1188
+ }
1189
+ }
1190
+ if (sourceRefJoinFailures.length > 0) {
1191
+ out.push("", "## Source reference join failures", "");
1192
+ for (const failure of sourceRefJoinFailures)
1193
+ out.push(`- ${caveatText(failure) || stringifyItem(failure)}`);
1194
+ }
1195
+ out.push(
1196
+ "",
1197
+ "## Renderer diagnostics",
1198
+ "",
1199
+ `- Executive word count: ${countWords(rendered.markdown)}.`,
1200
+ `- Rendered source URLs: ${rendered.sourceIndex.length}/${rendered.allSourceIndex.length}.`,
1201
+ `- Render warnings: ${rendered.renderWarnings.length}.`,
1202
+ "",
1203
+ );
1204
+ return out
1205
+ .join("\n")
1206
+ .replace(/\n{3,}/g, "\n\n")
1207
+ .trim();
1208
+ }
1209
+
274
1210
  export default async function renderExecutive({
275
1211
  sources,
276
1212
  options = {},
@@ -279,34 +1215,89 @@ export default async function renderExecutive({
279
1215
  const control =
280
1216
  findSource(sources, "final-audit") ??
281
1217
  sources?.[Object.keys(sources ?? {})[0]];
282
- const opts = {
283
- maxWords: Number(options.maxWords ?? 600),
284
- maxUrls: Number(options.maxUrls ?? 5),
285
- maxFindings: Number(options.maxFindings ?? 3),
286
- maxRecommendations: Number(options.maxRecommendations ?? 3),
287
- maxGaps: Number(options.maxGaps ?? 2),
288
- };
1218
+ const auditPacket = findSource(sources, "final-audit-packet");
289
1219
  if (!control || typeof control !== "object") {
290
1220
  return {
291
1221
  schema: "deep-research-executive-render-v1",
292
- digest: "Executive rendering failed: missing final-audit control source.",
1222
+ digest:
1223
+ "Research report rendering failed: missing final-audit control source.",
293
1224
  status: "blocked",
294
1225
  blockers: ["missing final-audit control source"],
295
1226
  executiveMarkdown: "",
1227
+ reportMarkdown: "",
1228
+ auditMarkdown: "",
296
1229
  wordCount: 0,
297
1230
  sourceUrlCount: 0,
298
- gates: { maxWords: opts.maxWords, maxUrls: opts.maxUrls, passed: false },
1231
+ totalSourceUrlCount: 0,
1232
+ sourceUrls: [],
1233
+ sourceIndex: [],
1234
+ claimSummary: {
1235
+ total: 0,
1236
+ verified: 0,
1237
+ partially_supported: 0,
1238
+ unsupported: 0,
1239
+ conflicting: 0,
1240
+ },
1241
+ factSlotSummary: {
1242
+ total: 0,
1243
+ filled: 0,
1244
+ partial: 0,
1245
+ missingOrConflicting: 0,
1246
+ },
1247
+ sectionCounts: {},
1248
+ renderWarnings: [],
1249
+ gates: {
1250
+ renderedAllStructuredItems: false,
1251
+ passed: false,
1252
+ },
1253
+ auditArtifact: "final-audit.control.json",
299
1254
  };
300
1255
  }
301
1256
 
302
- const rendered = renderExecutiveMarkdown(control, opts);
303
- const wordCount = countWords(rendered.markdown);
304
- const sourceUrlCount = rendered.sourceUrls.length;
305
- const passed = wordCount <= opts.maxWords && sourceUrlCount <= opts.maxUrls;
1257
+ const opts = {
1258
+ maxWords: Number.isFinite(Number(options.maxWords))
1259
+ ? Math.max(0, Number(options.maxWords))
1260
+ : Infinity,
1261
+ maxUrls: Number.isFinite(Number(options.maxUrls))
1262
+ ? Math.max(0, Number(options.maxUrls))
1263
+ : Infinity,
1264
+ maxFindings: Number.isFinite(Number(options.maxFindings))
1265
+ ? Math.max(0, Number(options.maxFindings))
1266
+ : undefined,
1267
+ maxRecommendations: Number.isFinite(Number(options.maxRecommendations))
1268
+ ? Math.max(0, Number(options.maxRecommendations))
1269
+ : undefined,
1270
+ maxGaps: Number.isFinite(Number(options.maxGaps))
1271
+ ? Math.max(0, Number(options.maxGaps))
1272
+ : undefined,
1273
+ };
1274
+ const rendered = renderResearchMarkdown(control, auditPacket, opts);
1275
+ let markdown = rendered.markdown;
1276
+ let truncated = false;
1277
+ if (Number.isFinite(opts.maxWords) && countWords(markdown) > opts.maxWords) {
1278
+ truncated = true;
1279
+ markdown = truncateWords(markdown, opts.maxWords);
1280
+ }
1281
+ const auditMarkdown = renderAuditMarkdown(control, auditPacket, rendered);
1282
+ const serializationArtifact =
1283
+ hasObjectSerializationArtifact(markdown) ||
1284
+ hasObjectSerializationArtifact(auditMarkdown);
1285
+ const wordCount = countWords(markdown);
1286
+ const sourceUrlCount = rendered.sourceIndex.length;
1287
+ const substantiveRenderWarnings = rendered.renderWarnings.filter(
1288
+ (warning) => warning.section !== "sourceUrls",
1289
+ );
1290
+ const renderedAllStructuredItems = substantiveRenderWarnings.length === 0;
1291
+ const truncatedWithOpenGaps =
1292
+ truncated && Number(rendered.sectionCounts.caveatsAndGaps ?? 0) > 0;
1293
+ const passed =
1294
+ renderedAllStructuredItems &&
1295
+ !truncatedWithOpenGaps &&
1296
+ !serializationArtifact;
306
1297
 
307
- // Best-effort sidecar for local inspection. The control field is still the
308
- // authoritative workflow artifact; this file is a convenience view.
309
- let sidecarPath;
1298
+ let executiveSidecarPath;
1299
+ let reportSidecarPath;
1300
+ let auditSidecarPath;
310
1301
  try {
311
1302
  if (context.cwd && context.runId && context.taskId) {
312
1303
  const taskDir = join(
@@ -318,36 +1309,52 @@ export default async function renderExecutive({
318
1309
  context.taskId,
319
1310
  );
320
1311
  await mkdir(taskDir, { recursive: true });
321
- sidecarPath = join(taskDir, "executive.md");
322
- await writeFile(sidecarPath, `${rendered.markdown}\n`, "utf8");
1312
+ executiveSidecarPath = join(taskDir, "executive.md");
1313
+ reportSidecarPath = join(taskDir, "report.md");
1314
+ auditSidecarPath = join(taskDir, "audit.md");
1315
+ await writeFile(executiveSidecarPath, `${markdown}\n`, "utf8");
1316
+ await writeFile(reportSidecarPath, `${markdown}\n`, "utf8");
1317
+ await writeFile(auditSidecarPath, `${auditMarkdown}\n`, "utf8");
323
1318
  }
324
1319
  } catch {
325
- // Sidecar is non-authoritative; keep control output deterministic.
1320
+ // Sidecars are non-authoritative; keep control output deterministic.
326
1321
  }
327
1322
 
328
1323
  return {
329
1324
  schema: "deep-research-executive-render-v1",
330
- digest: truncateWords(
331
- rendered.markdown.replace(/^# Executive summary\s*/i, ""),
332
- 45,
333
- ),
1325
+ digest: truncateWords(stripLeadingHeading(markdown), 45),
334
1326
  status: passed ? "passed" : "failed",
335
- executiveMarkdown: rendered.markdown,
1327
+ renderMode: "evidence-backed-report",
1328
+ executiveMarkdown: markdown,
1329
+ reportMarkdown: markdown,
1330
+ auditMarkdown,
336
1331
  wordCount,
337
1332
  sourceUrlCount,
338
- sourceUrls: rendered.sourceUrls,
339
- claimSummary: rendered.counts,
340
- factSlotSummary: rendered.factSlots,
1333
+ totalSourceUrlCount: rendered.allSourceIndex.length,
1334
+ sourceUrls: rendered.sourceIndex,
1335
+ sourceIndex: rendered.sourceIndex.map((url) => ({
1336
+ url,
1337
+ host: hostOf(url),
1338
+ })),
1339
+ claimSummary: rendered.claimSummary,
1340
+ factSlotSummary: rendered.factSlotSummary,
1341
+ sectionCounts: rendered.sectionCounts,
1342
+ renderWarnings: rendered.renderWarnings,
341
1343
  gates: {
342
- maxWords: opts.maxWords,
343
- maxUrls: opts.maxUrls,
1344
+ renderedAllStructuredItems,
1345
+ maxWords: Number.isFinite(opts.maxWords) ? opts.maxWords : null,
1346
+ maxUrls: Number.isFinite(opts.maxUrls) ? opts.maxUrls : null,
344
1347
  maxFindings: opts.maxFindings,
345
1348
  maxRecommendations: opts.maxRecommendations,
346
1349
  maxGaps: opts.maxGaps,
347
- truncated: rendered.truncated,
1350
+ truncated,
1351
+ truncatedWithOpenGaps,
1352
+ serializationArtifact,
348
1353
  passed,
349
1354
  },
350
- auditArtifact: "final-audit.control.json",
351
- ...(sidecarPath ? { sidecarPath } : {}),
1355
+ auditArtifact: auditSidecarPath ? "audit.md" : "final-audit.control.json",
1356
+ ...(executiveSidecarPath ? { sidecarPath: "executive.md" } : {}),
1357
+ ...(reportSidecarPath ? { reportSidecarPath: "report.md" } : {}),
1358
+ ...(auditSidecarPath ? { auditSidecarPath: "audit.md" } : {}),
352
1359
  };
353
1360
  }