@hilbras/omninode 0.6.0 → 0.7.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.
package/README.md CHANGED
@@ -18,7 +18,7 @@ existing intelligence; it does not try to become another model.
18
18
 
19
19
  ## Status
20
20
 
21
- **v0.6.0 — Phase 5: Pipeline Engine** (see [docs/DEVELOPMENT_PLAN.md](docs/DEVELOPMENT_PLAN.md)
21
+ **v0.7.0 — Phase 6: Multi-AI Report System** (see [docs/DEVELOPMENT_PLAN.md](docs/DEVELOPMENT_PLAN.md)
22
22
  for the full plan and roadmap).
23
23
 
24
24
  What exists today:
@@ -66,6 +66,14 @@ What exists today:
66
66
  - Failure handling: retries per step, `on-success`/`on-failure`/`always`
67
67
  conditions, combined context threaded downstream; every task runs through
68
68
  the TaskEngine and every run is persisted to `.omninode/pipelines.json`
69
+ - **Multi-AI report system** (`src/reports/`):
70
+ - Report collection from finished tasks — structured protocol reports pass
71
+ through; plain text output is normalized via deterministic extraction
72
+ (findings/recommendations/evidence sections, severity guessing)
73
+ - Persistence in `.omninode/reports.json` with full source attribution
74
+ - Aggregation (§17): duplicate/similar findings merged via deterministic
75
+ token-set similarity, sources preserved, highest severity kept
76
+ - Combined reports generated automatically at the end of every pipeline run
69
77
  - Typed error hierarchy (`src/errors/`)
70
78
  - Leveled logger with pluggable sink (`src/logger/`)
71
79
  - Configuration system: `omninode.yaml` with `${ENV_VAR}` expansion and strict
@@ -144,6 +152,8 @@ project:
144
152
 
145
153
  ```bash
146
154
  omninode pipeline run repo-audit "Audit the authentication module"
155
+ omninode report combined # the aggregated intelligence of that run
156
+ omninode report show combined-xxx # grouped findings with per-source attribution
147
157
  ```
148
158
 
149
159
  See [omninode.yaml.example](omninode.yaml.example) for a full configuration.
@@ -180,8 +190,7 @@ Phases 0–11 are laid out in [docs/DEVELOPMENT_PLAN.md](docs/DEVELOPMENT_PLAN.m
180
190
  | v0.3.0 | OmniHilbras Integration (optional, like every provider) |
181
191
  | v0.4.0 | Agent System |
182
192
  | v0.5.0 | Roles & Tasks |
183
- | **v0.6.0** | **Pipeline Engine (current)** |
184
- | v0.7.0 | Multi-AI Reports |
193
+ | **v0.7.0** | **Multi-AI Reports (current)** |
185
194
  | v0.8.0 | Remembera (optional memory integration) |
186
195
  | v0.9.0 | Planner |
187
196
  | v0.10.0 | End-to-End Runtime |
package/dist/cli/index.js CHANGED
@@ -6,7 +6,7 @@ import { realpathSync } from "fs";
6
6
  import { Command } from "commander";
7
7
 
8
8
  // src/version.ts
9
- var OMNINODE_VERSION = "0.6.0";
9
+ var OMNINODE_VERSION = "0.7.0";
10
10
 
11
11
  // src/errors/index.ts
12
12
  var OmniNodeError = class extends Error {
@@ -675,6 +675,9 @@ function notImplemented(command, when) {
675
675
  `${command} is planned for ${when}. Edit omninode.yaml directly for now.`
676
676
  );
677
677
  }
678
+ function truncate(text, max) {
679
+ return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
680
+ }
678
681
  function appendToConfigList(listKey, item) {
679
682
  const configPath = findConfigFile();
680
683
  if (!configPath) {
@@ -1404,6 +1407,8 @@ var TaskEngine = class {
1404
1407
  const result = {
1405
1408
  ...output.summary !== void 0 ? { summary: output.summary } : {},
1406
1409
  ...output.reports !== void 0 && output.reports.length > 0 ? { reports: output.reports } : {},
1410
+ // Keep the verbatim output (bounded) for report extraction and audit.
1411
+ ...output.rawOutput !== void 0 && output.rawOutput.length > 0 ? { rawOutput: output.rawOutput.slice(0, 1e4) } : {},
1407
1412
  ...output.error !== void 0 ? { error: output.error } : {},
1408
1413
  finishedAt: (/* @__PURE__ */ new Date()).toISOString()
1409
1414
  };
@@ -1538,7 +1543,7 @@ function registerTaskCommands(program) {
1538
1543
  return;
1539
1544
  }
1540
1545
  for (const t of tasks) {
1541
- console.log(`${t.id} ${t.status.padEnd(10)} ${truncate(t.objective, 60)}`);
1546
+ console.log(`${t.id} ${t.status.padEnd(10)} ${truncate2(t.objective, 60)}`);
1542
1547
  }
1543
1548
  });
1544
1549
  task.command("cancel <task-id>").description("Cancel a task that has not finished yet.").action(async (taskId) => {
@@ -1568,20 +1573,392 @@ function printOutcome(task, options = {}) {
1568
1573
  if (result.error) console.log(` error: ${result.error}`);
1569
1574
  if (result.finishedAt) console.log(` ended: ${result.finishedAt}`);
1570
1575
  }
1571
- function truncate(text, max) {
1576
+ function truncate2(text, max) {
1577
+ return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
1578
+ }
1579
+
1580
+ // src/reports/aggregate.ts
1581
+ import { randomBytes as randomBytes2 } from "crypto";
1582
+ var SEVERITY_RANK = {
1583
+ info: 0,
1584
+ low: 1,
1585
+ medium: 2,
1586
+ high: 3,
1587
+ critical: 4
1588
+ };
1589
+ function aggregateReports(reports, options = {}) {
1590
+ const threshold = options.similarityThreshold ?? 0.7;
1591
+ const findingGroups = [];
1592
+ const recommendationGroups = [];
1593
+ for (const report of reports) {
1594
+ for (const finding of report.findings) {
1595
+ mergeFinding(findingGroups, finding, report, threshold);
1596
+ }
1597
+ for (const text of report.recommendations) {
1598
+ mergeRecommendation(recommendationGroups, text, report, threshold);
1599
+ }
1600
+ }
1601
+ const sources = [...new Set(reports.map((r) => r.agent))].sort();
1602
+ const totalRawFindings = reports.reduce((sum, r) => sum + r.findings.length, 0);
1603
+ const summary = `${reports.length} report(s) from ${sources.length > 0 ? sources.join(", ") : "no agents"}: ${findingGroups.length} unique finding group(s) (${totalRawFindings} raw), ${recommendationGroups.length} recommendation group(s).`;
1604
+ return {
1605
+ id: `combined-${Date.now().toString(36)}-${randomBytes2(3).toString("hex")}`,
1606
+ title: options.objective !== void 0 ? truncate3(options.objective, 120) : "Combined report",
1607
+ ...options.objective !== void 0 ? { objective: options.objective } : {},
1608
+ ...options.pipelineRunId !== void 0 ? { pipelineRunId: options.pipelineRunId } : {},
1609
+ taskIds: options.taskIds ?? [],
1610
+ summary,
1611
+ findings: findingGroups.sort((a, b) => rank(b) - rank(a)),
1612
+ recommendations: recommendationGroups,
1613
+ sources,
1614
+ reportIds: reports.map((r) => r.id),
1615
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1616
+ };
1617
+ }
1618
+ function rank(finding) {
1619
+ return finding.severity ? SEVERITY_RANK[finding.severity] : -1;
1620
+ }
1621
+ function mergeFinding(groups, finding, report, threshold) {
1622
+ const source = { agent: report.agent, reportId: report.id };
1623
+ const group = groups.find((g) => similar(g.title, finding.title, threshold));
1624
+ if (group) {
1625
+ group.occurrences += 1;
1626
+ group.details.push(finding.detail);
1627
+ group.sources.push(source);
1628
+ group.evidence.push(...finding.evidence ?? []);
1629
+ if (finding.severity && (!group.severity || SEVERITY_RANK[finding.severity] > SEVERITY_RANK[group.severity])) {
1630
+ group.severity = finding.severity;
1631
+ }
1632
+ return;
1633
+ }
1634
+ groups.push({
1635
+ title: finding.title,
1636
+ details: [finding.detail],
1637
+ sources: [source],
1638
+ occurrences: 1,
1639
+ ...finding.severity ? { severity: finding.severity } : {},
1640
+ evidence: [...finding.evidence ?? []]
1641
+ });
1642
+ }
1643
+ function mergeRecommendation(groups, text, report, threshold) {
1644
+ const source = { agent: report.agent, reportId: report.id };
1645
+ const group = groups.find((g) => similar(g.text, text, threshold));
1646
+ if (group) {
1647
+ group.occurrences += 1;
1648
+ group.sources.push(source);
1649
+ return;
1650
+ }
1651
+ groups.push({ text, sources: [source], occurrences: 1 });
1652
+ }
1653
+ function similar(a, b, threshold) {
1654
+ const left = tokens(a);
1655
+ const right = tokens(b);
1656
+ if (left.size === 0 || right.size === 0) return false;
1657
+ let intersection = 0;
1658
+ for (const token of left) {
1659
+ if (right.has(token)) intersection += 1;
1660
+ }
1661
+ const union = left.size + right.size - intersection;
1662
+ return intersection / union >= threshold;
1663
+ }
1664
+ function tokens(text) {
1665
+ return new Set(
1666
+ text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((t) => t.length >= 3)
1667
+ );
1668
+ }
1669
+ function truncate3(text, max) {
1670
+ return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
1671
+ }
1672
+
1673
+ // src/reports/extract.ts
1674
+ var BULLET = /^(?:[-*•]|\d+[.)])\s+(.*)$/;
1675
+ function parseSections(raw) {
1676
+ const sections = [];
1677
+ let current = { heading: "", bullets: [] };
1678
+ for (const line of raw.split("\n")) {
1679
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line.trim());
1680
+ if (heading) {
1681
+ if (current.heading.length > 0 || current.bullets.length > 0) {
1682
+ sections.push(current);
1683
+ }
1684
+ current = { heading: heading[2]?.toLowerCase() ?? "", bullets: [] };
1685
+ continue;
1686
+ }
1687
+ const bullet = BULLET.exec(line.trim());
1688
+ if (bullet && current.heading.length > 0) {
1689
+ current.bullets.push(bullet[1]?.trim() ?? "");
1690
+ }
1691
+ }
1692
+ if (current.heading.length > 0 || current.bullets.length > 0) {
1693
+ sections.push(current);
1694
+ }
1695
+ return sections;
1696
+ }
1697
+ function collectBullets(sections, patterns) {
1698
+ const matches = sections.filter((section) => patterns.some((p) => p.test(section.heading)));
1699
+ return matches.flatMap((section) => section.bullets).filter((b) => b.length > 0);
1700
+ }
1701
+ function guessSeverity(text) {
1702
+ if (/critical|severe/i.test(text)) return "critical";
1703
+ if (/\bhigh\b|major/i.test(text)) return "high";
1704
+ if (/medium|moderate/i.test(text)) return "medium";
1705
+ if (/\blow\b|minor/i.test(text)) return "low";
1706
+ return void 0;
1707
+ }
1708
+ function extractReportFromText(raw, meta) {
1709
+ const trimmed = raw.trim();
1710
+ const nonEmpty = trimmed.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
1711
+ const proseLine = nonEmpty.find((line) => !line.startsWith("#") && !BULLET.test(line)) ?? nonEmpty[0] ?? "";
1712
+ const cleaned = proseLine.replace(/^#{1,6}\s+/, "").replace(BULLET, "$1");
1713
+ const summary = cleaned.length > 0 ? truncate4(cleaned, 200) : "(no output)";
1714
+ const sections = parseSections(trimmed);
1715
+ const findingTexts = collectBullets(sections, [/finding/, /issue/, /problem/, /vulnerab/]);
1716
+ const recommendationTexts = collectBullets(sections, [/recommend/, /suggestion/, /next step/, /action/]);
1717
+ const evidenceTexts = collectBullets(sections, [/evidence/, /proof/, /log/]);
1718
+ const findings = findingTexts.map((text, index) => ({
1719
+ id: `${meta.taskId}-f-${index + 1}`,
1720
+ title: truncate4(text, 120),
1721
+ detail: text,
1722
+ severity: guessSeverity(text)
1723
+ }));
1724
+ return {
1725
+ id: `${meta.taskId}-report`,
1726
+ taskId: meta.taskId,
1727
+ agent: meta.agent,
1728
+ ...meta.model !== void 0 ? { model: meta.model } : {},
1729
+ summary,
1730
+ findings,
1731
+ recommendations: recommendationTexts.map((t) => truncate4(t, 200)),
1732
+ metadata: evidenceTexts.length > 0 ? { evidence: evidenceTexts } : void 0,
1733
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1734
+ };
1735
+ }
1736
+ function truncate4(text, max) {
1572
1737
  return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
1573
1738
  }
1574
1739
 
1740
+ // src/reports/service.ts
1741
+ var ReportService = class {
1742
+ constructor(store, log = logger) {
1743
+ this.store = store;
1744
+ this.log = log.child({ component: "reports" });
1745
+ }
1746
+ store;
1747
+ log;
1748
+ get storeRef() {
1749
+ return this.store;
1750
+ }
1751
+ /** Normalizes finished tasks into reports (§17 — normalizer). */
1752
+ async collectFromTasks(tasks) {
1753
+ const reports = [];
1754
+ for (const task of tasks) {
1755
+ const structured = task.result?.reports ?? [];
1756
+ if (structured.length > 0) {
1757
+ reports.push(...structured);
1758
+ continue;
1759
+ }
1760
+ const text = task.result?.rawOutput ?? task.result?.summary ?? "";
1761
+ if (text.trim().length === 0) continue;
1762
+ reports.push(
1763
+ extractReportFromText(text, {
1764
+ taskId: task.id,
1765
+ agent: task.agent ?? "unknown"
1766
+ })
1767
+ );
1768
+ }
1769
+ return reports;
1770
+ }
1771
+ async saveReports(reports) {
1772
+ if (reports.length === 0) return;
1773
+ await this.store.saveReports(reports);
1774
+ this.log.info(`Stored ${reports.length} report(s).`);
1775
+ }
1776
+ async generateCombined(reports, meta = {}) {
1777
+ if (reports.length === 0) return void 0;
1778
+ const options = {
1779
+ ...meta.objective !== void 0 ? { objective: meta.objective } : {},
1780
+ ...meta.pipelineRunId !== void 0 ? { pipelineRunId: meta.pipelineRunId } : {},
1781
+ ...meta.taskIds !== void 0 ? { taskIds: meta.taskIds } : {}
1782
+ };
1783
+ const combined = aggregateReports(reports, options);
1784
+ await this.store.saveCombined(combined);
1785
+ this.log.info(`Generated combined report ${combined.id}.`);
1786
+ return combined;
1787
+ }
1788
+ };
1789
+
1790
+ // src/reports/store.ts
1791
+ import { mkdir as mkdir2, readFile as readFile2, rename as rename2, writeFile as writeFile2 } from "fs/promises";
1792
+ import { dirname as dirname2 } from "path";
1793
+ var FileReportStore = class {
1794
+ filePath;
1795
+ queue = Promise.resolve();
1796
+ constructor(directory = `${process.cwd()}/.omninode`) {
1797
+ this.filePath = `${directory}/reports.json`;
1798
+ }
1799
+ async saveReports(reports) {
1800
+ return this.synchronized(async () => {
1801
+ const entries = await this.load();
1802
+ for (const report of reports) {
1803
+ const index = entries.findIndex(
1804
+ (e) => e.kind === "report" && e.report.id === report.id
1805
+ );
1806
+ const entry2 = { kind: "report", report };
1807
+ if (index >= 0) entries[index] = entry2;
1808
+ else entries.push(entry2);
1809
+ }
1810
+ await this.write(entries);
1811
+ });
1812
+ }
1813
+ async listReports(filter = {}) {
1814
+ return this.synchronized(async () => {
1815
+ const entries = await this.load();
1816
+ return entries.filter((e) => e.kind === "report").map((e) => e.report).filter((report) => filter.taskId ? report.taskId === filter.taskId : true).filter((report) => filter.agent ? report.agent === filter.agent : true).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1817
+ });
1818
+ }
1819
+ async saveCombined(combined) {
1820
+ return this.synchronized(async () => {
1821
+ const entries = await this.load();
1822
+ const index = entries.findIndex(
1823
+ (e) => e.kind === "combined" && e.combined.id === combined.id
1824
+ );
1825
+ const entry2 = { kind: "combined", combined };
1826
+ if (index >= 0) entries[index] = entry2;
1827
+ else entries.push(entry2);
1828
+ await this.write(entries);
1829
+ });
1830
+ }
1831
+ async getCombined(id) {
1832
+ return this.synchronized(async () => {
1833
+ const entries = await this.load();
1834
+ const found = entries.find((e) => e.kind === "combined" && e.combined.id === id);
1835
+ return found?.kind === "combined" ? found.combined : void 0;
1836
+ });
1837
+ }
1838
+ async listCombined(filter = {}) {
1839
+ return this.synchronized(async () => {
1840
+ const entries = await this.load();
1841
+ return entries.filter((e) => e.kind === "combined").map((e) => e.combined).filter(
1842
+ (combined) => filter.pipelineRunId ? combined.pipelineRunId === filter.pipelineRunId : true
1843
+ ).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1844
+ });
1845
+ }
1846
+ synchronized(operation) {
1847
+ const result = this.queue.then(operation, operation);
1848
+ this.queue = result.then(
1849
+ () => void 0,
1850
+ () => void 0
1851
+ );
1852
+ return result;
1853
+ }
1854
+ async load() {
1855
+ try {
1856
+ const raw = await readFile2(this.filePath, "utf8");
1857
+ const parsed = JSON.parse(raw);
1858
+ return Array.isArray(parsed) ? parsed : [];
1859
+ } catch {
1860
+ return [];
1861
+ }
1862
+ }
1863
+ async write(entries) {
1864
+ const tmp = `${this.filePath}.tmp`;
1865
+ await mkdir2(dirname2(this.filePath), { recursive: true });
1866
+ await writeFile2(tmp, `${JSON.stringify(entries, null, 2)}
1867
+ `, "utf8");
1868
+ await rename2(tmp, this.filePath);
1869
+ }
1870
+ };
1871
+
1575
1872
  // src/cli/commands/reports.ts
1873
+ function service() {
1874
+ return new ReportService(new FileReportStore());
1875
+ }
1576
1876
  function registerReportCommands(program) {
1577
- const report = program.command("report").description("Inspect AI reports (Phase 6+).");
1578
- report.command("list").description("List collected reports (planned for Phase 6 \u2014 Multi-AI Report System).").action(() => {
1579
- throw notImplemented("omninode report list", "Phase 6 (Multi-AI Report System)");
1877
+ const report = program.command("report").description("Inspect AI reports collected from tasks and pipelines (\xA716\u2013\xA717).");
1878
+ report.command("list").description("List stored reports.").option("--task <taskId>", "Only reports for this task.").option("--agent <name>", "Only reports from this agent.").action(async (options) => {
1879
+ const svc = service();
1880
+ const reports = await svc.storeRef.listReports({
1881
+ ...options.task !== void 0 ? { taskId: options.task } : {},
1882
+ ...options.agent !== void 0 ? { agent: options.agent } : {}
1883
+ });
1884
+ if (reports.length === 0) {
1885
+ console.log("No reports stored yet. Run a pipeline to collect some.");
1886
+ return;
1887
+ }
1888
+ for (const r of reports) {
1889
+ console.log(`${r.id} ${r.agent.padEnd(10)} ${truncate(r.summary, 60)}`);
1890
+ }
1891
+ });
1892
+ report.command("combined").description("List combined reports (aggregated intelligence, \xA717).").option("--run <pipelineRunId>", "Only combined reports for this pipeline run.").action(async (options) => {
1893
+ const svc = service();
1894
+ const combined = await svc.storeRef.listCombined({
1895
+ ...options.run !== void 0 ? { pipelineRunId: options.run } : {}
1896
+ });
1897
+ if (combined.length === 0) {
1898
+ console.log("No combined reports stored yet. Run a pipeline to generate one.");
1899
+ return;
1900
+ }
1901
+ for (const c of combined) {
1902
+ console.log(
1903
+ `${c.id} ${c.findings.length} finding(s) from ${c.sources.length} source(s)${c.pipelineRunId ? ` run=${c.pipelineRunId}` : ""}`
1904
+ );
1905
+ }
1906
+ });
1907
+ report.command("show <id>").description("Show a stored report or combined report in full.").action(async (id) => {
1908
+ const svc = service();
1909
+ const reports = await svc.storeRef.listReports();
1910
+ const raw = reports.find((r) => r.id === id);
1911
+ if (raw) {
1912
+ console.log(`Report ${raw.id}`);
1913
+ console.log(` agent: ${raw.agent}${raw.model ? ` (${raw.model})` : ""}`);
1914
+ console.log(` task: ${raw.taskId}`);
1915
+ console.log(` summary: ${raw.summary}`);
1916
+ if (raw.findings.length > 0) {
1917
+ console.log(" findings:");
1918
+ for (const finding of raw.findings) {
1919
+ console.log(` - [${finding.severity ?? "info"}] ${finding.title}`);
1920
+ for (const evidence of finding.evidence ?? []) {
1921
+ console.log(` evidence: ${evidence}`);
1922
+ }
1923
+ }
1924
+ }
1925
+ if (raw.recommendations.length > 0) {
1926
+ console.log(" recommendations:");
1927
+ for (const recommendation of raw.recommendations) {
1928
+ console.log(` - ${recommendation}`);
1929
+ }
1930
+ }
1931
+ return;
1932
+ }
1933
+ const combined = await svc.storeRef.getCombined(id);
1934
+ if (combined) {
1935
+ console.log(`Combined report ${combined.id}`);
1936
+ console.log(` sources: ${combined.sources.join(", ")}`);
1937
+ console.log(` reports: ${combined.reportIds.length}`);
1938
+ if (combined.pipelineRunId) console.log(` run: ${combined.pipelineRunId}`);
1939
+ console.log(` summary: ${combined.summary}`);
1940
+ if (combined.findings.length > 0) {
1941
+ console.log(" findings (grouped, duplicates merged):");
1942
+ for (const finding of combined.findings) {
1943
+ console.log(
1944
+ ` - [${finding.severity ?? "info"}] ${finding.title} (x${finding.occurrences}, sources: ${finding.sources.map((s) => s.agent).join(", ")})`
1945
+ );
1946
+ }
1947
+ }
1948
+ if (combined.recommendations.length > 0) {
1949
+ console.log(" recommendations:");
1950
+ for (const recommendation of combined.recommendations) {
1951
+ console.log(` - ${recommendation.text} (x${recommendation.occurrences})`);
1952
+ }
1953
+ }
1954
+ return;
1955
+ }
1956
+ throw new OmniNodeError("REPORT_NOT_FOUND", `Report "${id}" was not found in the report store.`);
1580
1957
  });
1581
1958
  }
1582
1959
 
1583
1960
  // src/pipelines/engine.ts
1584
- import { randomBytes as randomBytes2 } from "crypto";
1961
+ import { randomBytes as randomBytes3 } from "crypto";
1585
1962
  var PipelineEngine = class {
1586
1963
  constructor(options) {
1587
1964
  this.options = options;
@@ -1694,7 +2071,7 @@ var PipelineEngine = class {
1694
2071
  }
1695
2072
  const now = (/* @__PURE__ */ new Date()).toISOString();
1696
2073
  const run2 = {
1697
- id: `run-${Date.now().toString(36)}-${randomBytes2(3).toString("hex")}`,
2074
+ id: `run-${Date.now().toString(36)}-${randomBytes3(3).toString("hex")}`,
1698
2075
  pipelineId: def.id,
1699
2076
  status: "running",
1700
2077
  startedAt: now,
@@ -1751,11 +2128,28 @@ ${contribution}`);
1751
2128
  }
1752
2129
  const failed = [...outcomes.values()].some((o) => o.status === "failed");
1753
2130
  const finalStatus = failed ? "failed" : "completed";
2131
+ let combinedReportId;
2132
+ if (this.options.reports) {
2133
+ const taskIds = [...outcomes.values()].flatMap((o) => o.taskIds);
2134
+ const tasks = (await Promise.all(taskIds.map((id) => this.options.tasks.get(id)))).filter((task) => task !== void 0);
2135
+ const collected = await this.options.reports.collectFromTasks(tasks);
2136
+ await this.options.reports.saveReports(collected);
2137
+ const combined = await this.options.reports.generateCombined(collected, {
2138
+ objective,
2139
+ pipelineRunId: run2.id,
2140
+ taskIds
2141
+ });
2142
+ combinedReportId = combined?.id;
2143
+ if (collected.length > 0) {
2144
+ run2.reports = collected;
2145
+ }
2146
+ }
1754
2147
  const finished = {
1755
2148
  ...run2,
1756
2149
  status: finalStatus,
1757
2150
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
1758
- ...allReports.length > 0 ? { reports: allReports } : {}
2151
+ ...run2.reports !== void 0 && run2.reports.length > 0 ? { reports: run2.reports } : {},
2152
+ ...combinedReportId !== void 0 ? { combinedReportId } : {}
1759
2153
  };
1760
2154
  await this.options.store.save(finished);
1761
2155
  this.log.info(`Pipeline ${def.id} finished: ${finalStatus}.`);
@@ -1935,8 +2329,8 @@ function createDefaultChatFn(providers) {
1935
2329
  }
1936
2330
 
1937
2331
  // src/pipelines/store.ts
1938
- import { mkdir as mkdir2, readFile as readFile2, rename as rename2, writeFile as writeFile2 } from "fs/promises";
1939
- import { dirname as dirname2 } from "path";
2332
+ import { mkdir as mkdir3, readFile as readFile3, rename as rename3, writeFile as writeFile3 } from "fs/promises";
2333
+ import { dirname as dirname3 } from "path";
1940
2334
  var FilePipelineRunStore = class {
1941
2335
  filePath;
1942
2336
  queue = Promise.resolve();
@@ -1974,7 +2368,7 @@ var FilePipelineRunStore = class {
1974
2368
  }
1975
2369
  async load() {
1976
2370
  try {
1977
- const raw = await readFile2(this.filePath, "utf8");
2371
+ const raw = await readFile3(this.filePath, "utf8");
1978
2372
  const parsed = JSON.parse(raw);
1979
2373
  return Array.isArray(parsed) ? parsed : [];
1980
2374
  } catch {
@@ -1983,10 +2377,10 @@ var FilePipelineRunStore = class {
1983
2377
  }
1984
2378
  async write(runs) {
1985
2379
  const tmp = `${this.filePath}.tmp`;
1986
- await mkdir2(dirname2(this.filePath), { recursive: true });
1987
- await writeFile2(tmp, `${JSON.stringify(runs, null, 2)}
2380
+ await mkdir3(dirname3(this.filePath), { recursive: true });
2381
+ await writeFile3(tmp, `${JSON.stringify(runs, null, 2)}
1988
2382
  `, "utf8");
1989
- await rename2(tmp, this.filePath);
2383
+ await rename3(tmp, this.filePath);
1990
2384
  }
1991
2385
  };
1992
2386
 
@@ -2007,6 +2401,7 @@ function buildPipelineEngine(config, log = logger) {
2007
2401
  providers: config.project.providers,
2008
2402
  store: new FilePipelineRunStore(),
2009
2403
  chat: createDefaultChatFn(config.project.providers),
2404
+ reports: new ReportService(new FileReportStore(), log),
2010
2405
  log
2011
2406
  });
2012
2407
  }
@@ -2045,6 +2440,9 @@ function registerPipelineCommands(program) {
2045
2440
  if (finished.reports && finished.reports.length > 0) {
2046
2441
  console.log(` reports: ${finished.reports.length} structured report(s)`);
2047
2442
  }
2443
+ if (finished.combinedReportId) {
2444
+ console.log(` combined report: ${finished.combinedReportId}`);
2445
+ }
2048
2446
  console.log(`Pipeline ${finished.status}. Run id: ${finished.id}`);
2049
2447
  if (finished.status !== "completed") process.exitCode = 1;
2050
2448
  });