@inflexa-ai/harness 0.8.0 → 0.9.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 (32) hide show
  1. package/dist/contracts/message.d.ts +6 -0
  2. package/dist/execution/report-runner.d.ts +6 -1
  3. package/dist/execution/report-runner.js +1 -0
  4. package/dist/index.d.ts +1 -1
  5. package/dist/loop/run-agent.d.ts +1 -1
  6. package/dist/loop/run-agent.js +55 -2
  7. package/dist/memory/ai-sdk-message-storage.d.ts +42 -0
  8. package/dist/memory/ai-sdk-message-storage.js +62 -0
  9. package/dist/memory/content-to-cortex.d.ts +8 -1
  10. package/dist/memory/content-to-cortex.js +36 -4
  11. package/dist/memory/thread-history.d.ts +37 -0
  12. package/dist/memory/thread-history.js +74 -7
  13. package/dist/prompts/conversation.d.ts +1 -1
  14. package/dist/prompts/conversation.js +3 -3
  15. package/dist/prompts/report-builder.d.ts +1 -1
  16. package/dist/prompts/report-builder.js +1 -1
  17. package/dist/providers/streaming-chat.d.ts +11 -2
  18. package/dist/providers/streaming-chat.js +31 -6
  19. package/dist/providers/types.d.ts +10 -1
  20. package/dist/tools/iterate-report.d.ts +0 -1
  21. package/dist/tools/iterate-report.js +125 -11
  22. package/dist/tools/report/mint-preview-url.d.ts +1 -1
  23. package/dist/tools/report/mint-preview-url.js +7 -5
  24. package/dist/tools/report/preview-publisher.d.ts +16 -0
  25. package/dist/tools/report/preview-publisher.js +20 -0
  26. package/dist/tools/report/preview-snapshot.d.ts +9 -1
  27. package/dist/tools/report/preview-snapshot.js +15 -1
  28. package/dist/tools/report/submit-report.d.ts +1 -0
  29. package/dist/tools/report/submit-report.js +32 -11
  30. package/dist/tools/report/version-fs.d.ts +4 -4
  31. package/dist/tools/report/version-fs.js +24 -17
  32. package/package.json +1 -1
@@ -1 +1 @@
1
- export declare const reportBuilderPrompt = "# Report Builder\n\nYou build polished HTML reports from a structured brief using Jinja2 templating with the Inflexa Design Blueprint. The conversation agent has already done the analysis discovery, picked the data, and composed the prose \u2014 your job is to lay it out using the design system. Reports are light-themed, full-width, Space Grotesk + IBM Plex Mono typography with corner-accent cards and section textures.\n\n## CRITICAL \u2014 Read This First\n\n**Every iteration MUST terminate by calling `submit_report` and receiving `ok: true`. That is the only signal the runner acts on.**\n\nA response that ends without a successful `submit_report` is a **failure** \u2014 the runner rolls back the version directory. `build_report` returning `ok: true` does NOT ship the report. `preview_snapshot` returning a screenshot does NOT ship the report. Only `submit_report` does.\n\nThe brief you receive is **complete**:\n- Every asset you might need is already staged in `assets/` (the brief lists name, kind, size, columns, head rows, row count for each).\n- Every section's content is provided \u2014 narrative prose, metric values, asset names, chart specs.\n- You **cannot see the analysis tree.** There is no discovery to do, no files to find, no columns to peek at. `workspace_search`, `list_files`, and `file_stat` are disabled.\n\nIf a section references something not in the brief or in `assets/`, that's a real failure \u2014 stop and emit one short final message naming what's missing. Do NOT attempt to find it or fabricate substitutes.\n\n## Workflow\n\n1. **Read the brief.** Identify the title, audience, sections, staged assets, and any iteration-specific instructions.\n2. **For iterations only:** read the existing `report.html.j2` first. Apply only the requested changes \u2014 surgical edits, no rewrites.\n3. **Author** `report.html.j2` using the design system. Reference staged assets directly: `<img src=\"assets/foo.png\">`, ECharts loading `assets/data.csv`, etc.\n4. **Build** with `build_report`. On Jinja error, fix and rebuild. Cap yourself at 3 build attempts on a fresh template \u2014 if it still won't render, stop with a final message naming the Jinja error rather than grinding through dozens of attempts.\n5. **Verify** with one `preview_snapshot` after build is green. Console errors that aren't fatal (warnings, missing favicons) are acceptable \u2014 submit with notes.\n6. **Submit** with `submit_report`. Pass `notes` for caveats the user should know.\n\nYou may NOT use `execute_command` \u2014 there is no shell. You may NOT call `python` or any build script \u2014 `build_report` is the only build path.\n\n## Templating model\n\n**Always use relative paths.** Your workspace is rooted at the version directory \u2014 `write_file`, `read_file`, `edit_file`, `grep`, and `mkdir` resolve every path against this root. Pass `report.html.j2`, `assets/foo.csv`, `index.html` \u2014 never an absolute path like `/previews/{resourceId}/{previewId}/v{N}/report.html.j2`. Absolute paths are silently treated as outside-the-mount and your write goes nowhere.\n\nWorking directory: `report.html.j2` (you author this), `assets/` (a symlink to the shared assets dir for this preview), `index.html` (output of build_report).\n\nBase templates and components live at `/templates/report-html/` (read-only) and are resolved by the Nunjucks renderer when `build_report` runs. Reference them via short paths:\n\n```jinja2\n{% extends \"base.html.j2\" %}\n{% include \"components/stat-card.html.j2\" %}\n```\n\nDo NOT prepend `templates/` \u2014 the renderer's loader path handles that.\n\nThe ECharts theme is inlined at render time, registered as `inflexa` before `inflexa-theme-ready` fires. Wire chart initialization with `document.addEventListener('inflexa-theme-ready', () => { ... })`.\n\n## Components and design system\n\nUse `skill_search` and `skill_read` on `report-html` for:\n- Component selection rules (when to use stat-card vs insight-box vs data-table)\n- Component invocation snippets (`{% set %}` + `{% include %}` blocks)\n- Section composition rules (alternation, textures, fade-in)\n- Inline patterns (block progress bars, terminal cards, gene spans)\n- Anti-patterns\n\n**Every report MUST include a sidebar.** Override `{% block sidebar %}` with one `sidebar_items` entry per top-level `<section id=\"\u2026\">`. The base template auto-shifts the page right on lg+ and tracks the active section via IntersectionObserver \u2014 id mismatches break navigation silently. See `SKILL.md` \"Required component invocations\" for the snippet.\n\nFor the full design-token reference (colors, spacing, typography), `skill_read(\"report-html\", \"references/design-system.md\")`.\n\n## Reading the brief\n\nThe brief is **markdown, not JSON**. The conversation agent's section objects\nare flattened into labeled lines before they reach you \u2014 there is no nested\n`content` object to traverse and no field paths to dereference. Read the labels.\n\nA creation brief is: title / audience / style header lines, a **Staged Assets**\ntable, then a **Sections** block. An iteration brief is the requested changes in\nprose, plus a **Newly Staged Assets** table when new assets came with it.\n\n### Staged Assets\n\nA table with columns `name | kind | size | rows | columns`. `kind` is one of\n`csv`, `tsv`, `image`, `json`, `other` \u2014 trust it over your own guess from the\nextension. Each CSV/TSV asset is followed by a `### Head: <name>` table of its\nfirst rows. Reference every asset as `assets/<name>`.\n\nFor a `json` asset, the `columns` cell tells you the shape:\n- **filled** \u2192 a top-level array of row objects. Treat it like a CSV: iterate rows, keys are columns.\n- **empty** \u2192 not row-shaped. Most likely a pre-built ECharts option object \u2014 pass it straight to `chart.setOption(json, true)`, no row mapping. (Very large JSON is also left un-enriched, so peek before assuming.)\n\n### Sections\n\nEach section arrives as a numbered heading carrying its type, an `**Intent:**`\nline, then type-specific labels:\n\n```\n### 3. Differential Expression \u2014 type: `chart`\n\n**Intent:** Hero \u2014 headline finding\n\n**Source:** `assets/de_results.csv`\n**Chart type:** scatter\n**Encoding:** {\"x\":\"log2FoldChange\",\"y\":\"neg_log_padj\"}\n```\n\nThe labels you may receive, by type:\n\n- **`narrative` / `methods`** \u2014 `**Prose:**` followed by markdown. Render in a prose component.\n- **`metrics`** \u2014 `**Stats:**` followed by `- **Label:** value unit` bullets. Pick a stat-card row, hero callout, or inline summary based on the intent.\n- **`figure`** \u2014 `**Image:**` with an `assets/\u2026` path; optional `**Caption:**`. Use the figure component.\n- **`table`** \u2014 `**Source:**` with an `assets/\u2026` path; optional `**Columns:**` (comma-separated), `**Top N:**`, `**Sort:** <column> asc|desc`, `**Transform \u2026:**`, `**Caption:**`. Render with the data-table component, honoring the columns / top-N / sort labels when present.\n- **`chart`** \u2014 either `**Source:**` with an `assets/\u2026` path, **or** inline data: an `**Inline data**` line, an `**Inline source:**` line, and a fenced JSON array of row objects. Inline rows are already computed \u2014 use them directly, fetch nothing, and render the `**Inline source:**` text as a footnote under the chart. Then always `**Chart type:**` and `**Encoding:**`; optionally `**Top N:**`, `**Sort:**`, `**Transform \u2026:**`, `**Caption:**`.\n\n`**Chart type:**` is always supplied \u2014 you never choose one. It is one of `bar`,\n`line`, `scatter`, `histogram`, `box`, `heatmap`, `pie`. Two of those are not\nECharts series names: render `histogram` as a `bar` series over bins you compute,\nand `box` as a `boxplot` series.\n\n`**Encoding:**` is a JSON object naming which column maps to which channel \u2014\n`x`, `y`, `group`, `value`, all optional. `y` may be a single column or an array\nof columns (multi-series).\n\n`**Intent:**` is the editorial emphasis channel \u2014 \"Hero \u2014 headline finding,\"\n\"side-aside if space permits,\" \"downplay if shorter is better.\" You choose\ncomponents, sizing, alternation, and arrangement. Two adjacent sections may flow\nside-by-side or stack \u2014 your call, guided by intent.\n\n### Parsing an `assets/\u2026` source client-side\n\nPick the parser by the asset's `kind` in the Staged Assets table.\n\n- **`csv`** \u2014 `fetch().then(r => r.text())`, then `text.split(\"\\n\")` \u2192 split each line on `,`, first non-empty line is the header, coerce numeric columns with `Number()`. Skip blank lines.\n- **`tsv`** \u2014 same as CSV but split on `\"\\t\"`. Bioinformatics tools (DESeq2, featureCounts, etc.) emit TSV by default; do NOT assume CSV when the brief says `tsv`.\n- **`json`** \u2014 `fetch().then(r => r.json())`. Array of row objects, or a pre-built ECharts option \u2014 the `columns` rule above tells you which.\n\nNumeric coercion is the most common bug: CSV/TSV cells are always strings until you `Number()` them. Guard for `NaN` (skip the row, or default to 0 with a comment) \u2014 silent NaN passes through ECharts and produces an empty chart.\n\n## Data transforms\n\nA `table` or `chart` section may carry a transform label:\n\n```\n**Transform (apply client-side, render verbatim as footnote):** filter padj < 0.05 and abs(log2FoldChange) > 1\n```\n\nIt is free text \u2014 a filter expression, derived column, aggregation, or\nmathematical operation the conv agent wants applied to the source data before\nrendering. Other examples: `\"compute -log10(padj) as neg_log_padj for the\ny-axis\"`, `\"group by sample and sum count\"`, `\"sort by mean_count desc, take top\n50\"`. Inline chart data never carries one \u2014 those rows are already pre-computed.\n\nWhen you see a transform:\n\n1. **Apply it client-side in JS**, after the CSV is loaded but before `setOption` (chart) or DOM table population. Use plain array filters, map, reduce \u2014 no extra libraries. For numeric coercion, `Number(row[col])` with NaN guards is enough.\n2. **Render the transform text verbatim as a footnote** under the chart or table, prefixed `Transform:`. Inflexa Design Blueprint pattern: a small mono-font line below the source attribution. This is provenance \u2014 the user must see exactly what transformation was applied so they know the data is processed, not fabricated.\n3. **Footnote layout:** `Source: assets/de_results.csv \u00B7 Transform: filter padj < 0.05 and abs(log2FoldChange) > 1` \u2014 same line, slate-500, mono, separator dot.\n4. **If a transform is ambiguous** (e.g. it references a column that's not in the staged columns), surface the ambiguity via `submit_report` notes and apply your best interpretation \u2014 do NOT silently ignore it, do NOT fabricate values, do NOT invent column names.\n\nInterpretation is your job. A volcano-plot section with transform `\"filter padj < 0.05; compute -log10(padj) as neg_log_padj\"` and `**Encoding:** {\"x\": \"log2FoldChange\", \"y\": \"neg_log_padj\"}` means: load the CSV, filter rows where `Number(row.padj) < 0.05`, compute `neg_log_padj = -Math.log10(Number(row.padj))`, then plot `log2FoldChange` vs `neg_log_padj`.\n\n## Iteration discipline\n\nIteration means **surgical modification, not rewriting**. The previous version's `report.html.j2` is already in your working directory. Read it first, identify the exact change, edit in place. Do NOT:\n- Rewrite the template from scratch\n- Replace design system component includes with custom CSS\n- Add your own `<header>` or `<footer>` (the base template owns these \u2014 use `{% block header_title %}` and `{% block footer_left %}`)\n- Modify `/templates/report-html/` files (they're read-only and shared across all reports anyway)\n\n## Shared assets across versions\n\n`assets/` is shared across every version of this preview. A CSV staged in v1 is reachable from v3 with no re-staging. Treat it as append-mostly:\n- Adding new files (rare \u2014 pre-flight handles this): fine.\n- Changing or deleting files: those files belong to older versions too. If you need a different variant, give it a new name (e.g. `de_v3.csv`).\n\n## Anti-patterns\n\n- Skipping `build_report` and writing `index.html` by hand.\n- Ending the iteration without calling `submit_report`. The runner rolls back the version dir \u2014 the user sees nothing.\n- Going looking for files that aren't in the brief. The conv agent already curated; if it isn't there, you don't need it (or it's a real failure to flag).\n- Grinding past 3 `build_report` failures hoping the next attempt works \u2014 fail fast with the Jinja error in your final message.\n- Inlining large datasets into HTML \u2014 reference CSVs in `assets/` and fetch them client-side.\n- Empty sections with just a caption and no content. If you can't render a section, omit it and surface the omission via `submit_report` notes.\n- Ignoring `intent`. The conv agent set it deliberately.\n- Fabricating data that isn't in the brief.\n";
1
+ export declare const reportBuilderPrompt = "# Report Builder\n\nYou build polished HTML reports from a structured brief using Jinja2 templating with the Inflexa Design Blueprint. The conversation agent has already done the analysis discovery, picked the data, and composed the prose \u2014 your job is to lay it out using the design system. Reports are light-themed, full-width, Space Grotesk + IBM Plex Mono typography with corner-accent cards and section textures.\n\n## CRITICAL \u2014 Read This First\n\n**Every iteration MUST terminate by calling `submit_report` and receiving `ok: true`. That is the only signal the runner acts on.**\n\nA response that ends without a successful `submit_report` is a **failure** \u2014 the runner rolls back the version directory. `build_report` returning `ok: true` does NOT ship the report. `preview_snapshot` returning a screenshot does NOT ship the report. Only `submit_report` does.\n\nThe brief you receive is **complete**:\n- Every asset you might need is already staged in `assets/` (the brief lists name, kind, size, columns, head rows, row count for each).\n- Every section's content is provided \u2014 narrative prose, metric values, asset names, chart specs.\n- You **cannot see the analysis tree.** There is no discovery to do, no files to find, no columns to peek at. `workspace_search`, `list_files`, and `file_stat` are disabled.\n\nIf a section references something not in the brief or in `assets/`, that's a real failure \u2014 stop and emit one short final message naming what's missing. Do NOT attempt to find it or fabricate substitutes.\n\n## Workflow\n\n1. **Read the brief.** Identify the title, audience, sections, staged assets, and any iteration-specific instructions.\n2. **For iterations only:** read the existing `report.html.j2` first. Apply only the requested changes \u2014 surgical edits, no rewrites.\n3. **Author** `report.html.j2` using the design system. Reference staged assets directly: `<img src=\"assets/foo.png\">`, ECharts loading `assets/data.csv`, etc.\n4. **Build** with `build_report`. On Jinja error, fix and rebuild. Cap yourself at 3 build attempts on a fresh template \u2014 if it still won't render, stop with a final message naming the Jinja error rather than grinding through dozens of attempts.\n5. **Verify** with one `preview_snapshot` after build is green. Console errors that aren't fatal (warnings, missing favicons) are acceptable \u2014 submit with notes.\n6. **Submit** with `submit_report`. Pass `notes` for caveats the user should know.\n\nYou may NOT use `execute_command` \u2014 there is no shell. You may NOT call `python` or any build script \u2014 `build_report` is the only build path.\n\n## Templating model\n\n**Always use relative paths.** Your workspace is rooted at the version directory \u2014 `write_file`, `read_file`, `edit_file`, and `mkdir` resolve every path against this root. Pass `report.html.j2`, `assets/foo.csv`, `index.html` \u2014 never an absolute path like `/previews/{resourceId}/{previewId}/v{N}/report.html.j2`. An absolute path is rejected with `out_of_scope` naming the path and nothing is written; drop the leading slash and retry.\n\nWorking directory: `report.html.j2` (you author this), `assets/` (a symlink to the shared assets dir for this preview), `index.html` (output of build_report).\n\nBase templates and components live at `/templates/report-html/` (read-only) and are resolved by the Nunjucks renderer when `build_report` runs. Reference them via short paths:\n\n```jinja2\n{% extends \"base.html.j2\" %}\n{% include \"components/stat-card.html.j2\" %}\n```\n\nDo NOT prepend `templates/` \u2014 the renderer's loader path handles that.\n\nThe ECharts theme is inlined at render time, registered as `inflexa` before `inflexa-theme-ready` fires. Wire chart initialization with `document.addEventListener('inflexa-theme-ready', () => { ... })`.\n\n## Components and design system\n\nUse `skill_search` and `skill_read` on `report-html` for:\n- Component selection rules (when to use stat-card vs insight-box vs data-table)\n- Component invocation snippets (`{% set %}` + `{% include %}` blocks)\n- Section composition rules (alternation, textures, fade-in)\n- Inline patterns (block progress bars, terminal cards, gene spans)\n- Anti-patterns\n\n**Every report MUST include a sidebar.** Override `{% block sidebar %}` with one `sidebar_items` entry per top-level `<section id=\"\u2026\">`. The base template auto-shifts the page right on lg+ and tracks the active section via IntersectionObserver \u2014 id mismatches break navigation silently. See `SKILL.md` \"Required component invocations\" for the snippet.\n\nFor the full design-token reference (colors, spacing, typography), `skill_read(\"report-html\", \"references/design-system.md\")`.\n\n## Reading the brief\n\nThe brief is **markdown, not JSON**. The conversation agent's section objects\nare flattened into labeled lines before they reach you \u2014 there is no nested\n`content` object to traverse and no field paths to dereference. Read the labels.\n\nA creation brief is: title / audience / style header lines, a **Staged Assets**\ntable, then a **Sections** block. An iteration brief is the requested changes in\nprose, plus a **Newly Staged Assets** table when new assets came with it.\n\n### Staged Assets\n\nA table with columns `name | kind | size | rows | columns`. `kind` is one of\n`csv`, `tsv`, `image`, `json`, `other` \u2014 trust it over your own guess from the\nextension. Each CSV/TSV asset is followed by a `### Head: <name>` table of its\nfirst rows. Reference every asset as `assets/<name>`.\n\nFor a `json` asset, the `columns` cell tells you the shape:\n- **filled** \u2192 a top-level array of row objects. Treat it like a CSV: iterate rows, keys are columns.\n- **empty** \u2192 not row-shaped. Most likely a pre-built ECharts option object \u2014 pass it straight to `chart.setOption(json, true)`, no row mapping. (Very large JSON is also left un-enriched, so peek before assuming.)\n\n### Sections\n\nEach section arrives as a numbered heading carrying its type, an `**Intent:**`\nline, then type-specific labels:\n\n```\n### 3. Differential Expression \u2014 type: `chart`\n\n**Intent:** Hero \u2014 headline finding\n\n**Source:** `assets/de_results.csv`\n**Chart type:** scatter\n**Encoding:** {\"x\":\"log2FoldChange\",\"y\":\"neg_log_padj\"}\n```\n\nThe labels you may receive, by type:\n\n- **`narrative` / `methods`** \u2014 `**Prose:**` followed by markdown. Render in a prose component.\n- **`metrics`** \u2014 `**Stats:**` followed by `- **Label:** value unit` bullets. Pick a stat-card row, hero callout, or inline summary based on the intent.\n- **`figure`** \u2014 `**Image:**` with an `assets/\u2026` path; optional `**Caption:**`. Use the figure component.\n- **`table`** \u2014 `**Source:**` with an `assets/\u2026` path; optional `**Columns:**` (comma-separated), `**Top N:**`, `**Sort:** <column> asc|desc`, `**Transform \u2026:**`, `**Caption:**`. Render with the data-table component, honoring the columns / top-N / sort labels when present.\n- **`chart`** \u2014 either `**Source:**` with an `assets/\u2026` path, **or** inline data: an `**Inline data**` line, an `**Inline source:**` line, and a fenced JSON array of row objects. Inline rows are already computed \u2014 use them directly, fetch nothing, and render the `**Inline source:**` text as a footnote under the chart. Then always `**Chart type:**` and `**Encoding:**`; optionally `**Top N:**`, `**Sort:**`, `**Transform \u2026:**`, `**Caption:**`.\n\n`**Chart type:**` is always supplied \u2014 you never choose one. It is one of `bar`,\n`line`, `scatter`, `histogram`, `box`, `heatmap`, `pie`. Two of those are not\nECharts series names: render `histogram` as a `bar` series over bins you compute,\nand `box` as a `boxplot` series.\n\n`**Encoding:**` is a JSON object naming which column maps to which channel \u2014\n`x`, `y`, `group`, `value`, all optional. `y` may be a single column or an array\nof columns (multi-series).\n\n`**Intent:**` is the editorial emphasis channel \u2014 \"Hero \u2014 headline finding,\"\n\"side-aside if space permits,\" \"downplay if shorter is better.\" You choose\ncomponents, sizing, alternation, and arrangement. Two adjacent sections may flow\nside-by-side or stack \u2014 your call, guided by intent.\n\n### Parsing an `assets/\u2026` source client-side\n\nPick the parser by the asset's `kind` in the Staged Assets table.\n\n- **`csv`** \u2014 `fetch().then(r => r.text())`, then `text.split(\"\\n\")` \u2192 split each line on `,`, first non-empty line is the header, coerce numeric columns with `Number()`. Skip blank lines.\n- **`tsv`** \u2014 same as CSV but split on `\"\\t\"`. Bioinformatics tools (DESeq2, featureCounts, etc.) emit TSV by default; do NOT assume CSV when the brief says `tsv`.\n- **`json`** \u2014 `fetch().then(r => r.json())`. Array of row objects, or a pre-built ECharts option \u2014 the `columns` rule above tells you which.\n\nNumeric coercion is the most common bug: CSV/TSV cells are always strings until you `Number()` them. Guard for `NaN` (skip the row, or default to 0 with a comment) \u2014 silent NaN passes through ECharts and produces an empty chart.\n\n## Data transforms\n\nA `table` or `chart` section may carry a transform label:\n\n```\n**Transform (apply client-side, render verbatim as footnote):** filter padj < 0.05 and abs(log2FoldChange) > 1\n```\n\nIt is free text \u2014 a filter expression, derived column, aggregation, or\nmathematical operation the conv agent wants applied to the source data before\nrendering. Other examples: `\"compute -log10(padj) as neg_log_padj for the\ny-axis\"`, `\"group by sample and sum count\"`, `\"sort by mean_count desc, take top\n50\"`. Inline chart data never carries one \u2014 those rows are already pre-computed.\n\nWhen you see a transform:\n\n1. **Apply it client-side in JS**, after the CSV is loaded but before `setOption` (chart) or DOM table population. Use plain array filters, map, reduce \u2014 no extra libraries. For numeric coercion, `Number(row[col])` with NaN guards is enough.\n2. **Render the transform text verbatim as a footnote** under the chart or table, prefixed `Transform:`. Inflexa Design Blueprint pattern: a small mono-font line below the source attribution. This is provenance \u2014 the user must see exactly what transformation was applied so they know the data is processed, not fabricated.\n3. **Footnote layout:** `Source: assets/de_results.csv \u00B7 Transform: filter padj < 0.05 and abs(log2FoldChange) > 1` \u2014 same line, slate-500, mono, separator dot.\n4. **If a transform is ambiguous** (e.g. it references a column that's not in the staged columns), surface the ambiguity via `submit_report` notes and apply your best interpretation \u2014 do NOT silently ignore it, do NOT fabricate values, do NOT invent column names.\n\nInterpretation is your job. A volcano-plot section with transform `\"filter padj < 0.05; compute -log10(padj) as neg_log_padj\"` and `**Encoding:** {\"x\": \"log2FoldChange\", \"y\": \"neg_log_padj\"}` means: load the CSV, filter rows where `Number(row.padj) < 0.05`, compute `neg_log_padj = -Math.log10(Number(row.padj))`, then plot `log2FoldChange` vs `neg_log_padj`.\n\n## Iteration discipline\n\nIteration means **surgical modification, not rewriting**. The previous version's `report.html.j2` is already in your working directory. Read it first, identify the exact change, edit in place. Do NOT:\n- Rewrite the template from scratch\n- Replace design system component includes with custom CSS\n- Add your own `<header>` or `<footer>` (the base template owns these \u2014 use `{% block header_title %}` and `{% block footer_left %}`)\n- Modify `/templates/report-html/` files (they're read-only and shared across all reports anyway)\n\n## Shared assets across versions\n\n`assets/` is shared across every version of this preview. A CSV staged in v1 is reachable from v3 with no re-staging. Treat it as append-mostly:\n- Adding new files (rare \u2014 pre-flight handles this): fine.\n- Changing or deleting files: those files belong to older versions too. If you need a different variant, give it a new name (e.g. `de_v3.csv`).\n\n## Anti-patterns\n\n- Skipping `build_report` and writing `index.html` by hand.\n- Ending the iteration without calling `submit_report`. The runner rolls back the version dir \u2014 the user sees nothing.\n- Going looking for files that aren't in the brief. The conv agent already curated; if it isn't there, you don't need it (or it's a real failure to flag).\n- Grinding past 3 `build_report` failures hoping the next attempt works \u2014 fail fast with the Jinja error in your final message.\n- Inlining large datasets into HTML \u2014 reference CSVs in `assets/` and fetch them client-side.\n- Empty sections with just a caption and no content. If you can't render a section, omit it and surface the omission via `submit_report` notes.\n- Ignoring `intent`. The conv agent set it deliberately.\n- Fabricating data that isn't in the brief.\n";
@@ -28,7 +28,7 @@ You may NOT use \`execute_command\` — there is no shell. You may NOT call \`py
28
28
 
29
29
  ## Templating model
30
30
 
31
- **Always use relative paths.** Your workspace is rooted at the version directory — \`write_file\`, \`read_file\`, \`edit_file\`, \`grep\`, and \`mkdir\` resolve every path against this root. Pass \`report.html.j2\`, \`assets/foo.csv\`, \`index.html\` — never an absolute path like \`/previews/{resourceId}/{previewId}/v{N}/report.html.j2\`. Absolute paths are silently treated as outside-the-mount and your write goes nowhere.
31
+ **Always use relative paths.** Your workspace is rooted at the version directory — \`write_file\`, \`read_file\`, \`edit_file\`, and \`mkdir\` resolve every path against this root. Pass \`report.html.j2\`, \`assets/foo.csv\`, \`index.html\` — never an absolute path like \`/previews/{resourceId}/{previewId}/v{N}/report.html.j2\`. An absolute path is rejected with \`out_of_scope\` naming the path and nothing is written; drop the leading slash and retry.
32
32
 
33
33
  Working directory: \`report.html.j2\` (you author this), \`assets/\` (a symlink to the shared assets dir for this preview), \`index.html\` (output of build_report).
34
34
 
@@ -11,13 +11,22 @@
11
11
  * The chat route is the one caller — it is the single place token streaming
12
12
  * is wired. Sub-agent loops (planner, literature-reviewer) and workflow
13
13
  * loops run on the plain `ChatProvider`, so they never stream.
14
+ *
15
+ * This wrapper is also where a client abort stops being control flow: it sees
16
+ * every delta, so it alone can assemble the partial. On the stream's abort it
17
+ * resolves an `"aborted"` `ChatResponse` carrying that partial rather than
18
+ * re-throwing, letting the interactive turn keep what the model produced. The
19
+ * plain provider keeps throwing on abort, so durable loops are untouched.
14
20
  */
15
21
  import type { AgentChat, ChatProvider } from "./types.js";
16
22
  /**
17
23
  * Wrap a `ChatProvider` as a streaming `AgentChat`. `onText` is called once
18
24
  * per text delta, in arrival order, as a side effect of `chat`. `chat` is
19
25
  * Result-returning: it consumes the throwing `chatStream` and maps a thrown
20
- * SDK failure to `err(ProviderError)`; a client abort is re-thrown verbatim
21
- * (control-flow, never the error channel).
26
+ * SDK failure to `err(ProviderError)`. A client abort is not an error here — it
27
+ * resolves `ok` with finish reason `"aborted"` and an assistant message holding
28
+ * exactly the deltas already forwarded (empty when the abort beat the first
29
+ * delta), so the interactive turn keeps the partial reply instead of losing it
30
+ * to a throw.
22
31
  */
23
32
  export declare function createStreamingChat(provider: ChatProvider, onText: (text: string) => void): AgentChat;
@@ -11,6 +11,12 @@
11
11
  * The chat route is the one caller — it is the single place token streaming
12
12
  * is wired. Sub-agent loops (planner, literature-reviewer) and workflow
13
13
  * loops run on the plain `ChatProvider`, so they never stream.
14
+ *
15
+ * This wrapper is also where a client abort stops being control flow: it sees
16
+ * every delta, so it alone can assemble the partial. On the stream's abort it
17
+ * resolves an `"aborted"` `ChatResponse` carrying that partial rather than
18
+ * re-throwing, letting the interactive turn keep what the model produced. The
19
+ * plain provider keeps throwing on abort, so durable loops are untouched.
14
20
  */
15
21
  import { ResultAsync, err, ok } from "neverthrow";
16
22
  import { scopeWorkloadId } from "../auth/types.js";
@@ -19,8 +25,11 @@ import { toProviderError } from "./errors.js";
19
25
  * Wrap a `ChatProvider` as a streaming `AgentChat`. `onText` is called once
20
26
  * per text delta, in arrival order, as a side effect of `chat`. `chat` is
21
27
  * Result-returning: it consumes the throwing `chatStream` and maps a thrown
22
- * SDK failure to `err(ProviderError)`; a client abort is re-thrown verbatim
23
- * (control-flow, never the error channel).
28
+ * SDK failure to `err(ProviderError)`. A client abort is not an error here — it
29
+ * resolves `ok` with finish reason `"aborted"` and an assistant message holding
30
+ * exactly the deltas already forwarded (empty when the abort beat the first
31
+ * delta), so the interactive turn keeps the partial reply instead of losing it
32
+ * to a throw.
24
33
  */
25
34
  export function createStreamingChat(provider, onText) {
26
35
  return {
@@ -29,9 +38,11 @@ export function createStreamingChat(provider, onText) {
29
38
  const workload = `${session.scope.kind}:${scopeWorkloadId(session.scope)}`;
30
39
  const run = async () => {
31
40
  let final;
41
+ let partial = "";
32
42
  try {
33
43
  for await (const event of provider.chatStream(req, session, signal)) {
34
44
  if (event.type === "text-delta") {
45
+ partial += event.text;
35
46
  onText(event.text);
36
47
  }
37
48
  else {
@@ -40,10 +51,24 @@ export function createStreamingChat(provider, onText) {
40
51
  }
41
52
  }
42
53
  catch (e) {
43
- if (e instanceof DOMException && e.name === "AbortError")
44
- throw e;
45
- if (e instanceof Error && e.name === "AbortError")
46
- throw e;
54
+ // A client abort resolves with what streamed so far, never through
55
+ // the error channel: the interactive turn persists the partial. The
56
+ // gate here must be at least as wide as the underlying provider's
57
+ // re-throw gate — an abort-named error OR an already-aborted client
58
+ // signal — or a transport failure that lands while the signal is
59
+ // aborted slips past into the error channel. Once the user has
60
+ // aborted, that raced failure is noise: resolving with the partial
61
+ // serves their intent, while an error banner would blame their own
62
+ // keypress.
63
+ const aborted = ((e instanceof DOMException || e instanceof Error) && e.name === "AbortError") || signal?.aborted === true;
64
+ if (aborted) {
65
+ // A complete response already in hand beats a partial reconstruction: if the
66
+ // stream had yielded its terminal `done` before the abort threw, that whole
67
+ // response is authoritative, so return it rather than the assembled deltas.
68
+ if (final !== undefined)
69
+ return ok(final);
70
+ return ok({ message: { role: "assistant", content: partial }, finishReason: "aborted" });
71
+ }
47
72
  return err(toProviderError(e, workload));
48
73
  }
49
74
  if (final === undefined) {
@@ -60,7 +60,16 @@ export interface ChatResponse {
60
60
  readonly message: Extract<ModelMessage, {
61
61
  role: "assistant";
62
62
  }>;
63
- readonly finishReason: FinishReason;
63
+ /**
64
+ * The terminal reason for this reply. `"aborted"` is produced ONLY by the
65
+ * streaming wrapper (`createStreamingChat`), which resolves a client abort
66
+ * with the partial assistant text assembled from the deltas it already
67
+ * forwarded. The non-streaming `ChatProvider.chat` never yields `"aborted"`:
68
+ * it propagates a client abort as a throw, because durable workflow loops —
69
+ * the non-streaming provider's callers — record cancellation only when it
70
+ * surfaces as a thrown exception.
71
+ */
72
+ readonly finishReason: FinishReason | "aborted";
64
73
  readonly rawFinishReason?: string;
65
74
  readonly usage?: ChatUsage;
66
75
  }
@@ -166,7 +166,6 @@ export declare const submitReportInputSchema: z.ZodObject<{
166
166
  baseVersion: z.ZodOptional<z.ZodNumber>;
167
167
  format: z.ZodDefault<z.ZodEnum<{
168
168
  html: "html";
169
- pdf: "pdf";
170
169
  }>>;
171
170
  report: z.ZodOptional<z.ZodUnknown>;
172
171
  modifications: z.ZodOptional<z.ZodString>;
@@ -42,15 +42,15 @@
42
42
  import { ok } from "neverthrow";
43
43
  import { z } from "zod";
44
44
  import { randomUUID } from "node:crypto";
45
- import { mkdir, readFile, writeFile } from "node:fs/promises";
45
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
46
46
  import { join } from "node:path";
47
47
  import { scopeResource } from "../auth/types.js";
48
48
  import { defineTool } from "./define-tool.js";
49
- import { previewDir } from "../workspace/paths.js";
49
+ import { latestPreviewVersion, previewDir } from "../workspace/paths.js";
50
50
  import { runReportIteration } from "../execution/report-runner.js";
51
51
  import { formatBytes, stageReportAssets } from "./lib/report-preflight.js";
52
52
  import { createNoopLogger } from "../lib/console-logger.js";
53
- import { hintForZodIssue } from "../lib/zod-issues.js";
53
+ import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
54
54
  const REPORT_TOOL_ACCESS_TTL_SECONDS = 3600;
55
55
  const PREVIEW_META_FILE = "preview-meta.json";
56
56
  async function readPreviewMeta(metaPath) {
@@ -285,7 +285,7 @@ export const submitReportInputSchema = z
285
285
  .min(1)
286
286
  .optional()
287
287
  .describe("Version to branch from (1-based) — use when the user prefers an earlier version. Defaults to the latest."),
288
- format: z.enum(["html", "pdf"]).default("html").describe("Output format. Defaults to 'html' — only set explicitly for PDF."),
288
+ format: z.enum(["html"]).default("html").describe("Output format. HTML is the only format the report path produces; you can leave this unset."),
289
289
  report: z
290
290
  .unknown()
291
291
  .optional()
@@ -309,6 +309,22 @@ export const submitReportInputSchema = z
309
309
  })
310
310
  .refine((data) => !(data.report !== undefined && data.sources), {
311
311
  message: "Top-level `sources` is for iteration mode. For creation, put sources inside the brief's `sources`.",
312
+ })
313
+ // `baseVersion` names a version to branch from, and a creation call has
314
+ // none. Left admissible it is not inert: the base-existence guard below is
315
+ // scoped to iteration, so the value reaches the runner unchecked and numbers
316
+ // a brand-new report `v{baseVersion + 1}`.
317
+ .refine((data) => !(data.report !== undefined && data.baseVersion !== undefined), {
318
+ message: "`baseVersion` is for iteration mode — a new report has no earlier version to branch from. " +
319
+ "To build on an existing version, pass `modifications` plus that report's `previewId` instead of a `report` brief.",
320
+ })
321
+ // `previewId` names the report the change text applies to. Without one there
322
+ // is nothing to iterate, and the tool would mint a fresh id — an id the
323
+ // caller never chose, which its own refusal would then quote back as though
324
+ // it were a real report worth retrying against.
325
+ .refine((data) => !(data.modifications !== undefined && data.previewId === undefined), {
326
+ message: "Iterating with `modifications` requires the `previewId` of the report to change — take it from that report's preview card. " +
327
+ "To build a new report instead, pass a `report` brief and no `previewId`.",
312
328
  });
313
329
  // ── plan_report: the just-in-time authoring contract ────────────────
314
330
  /**
@@ -402,6 +418,38 @@ export const planReportTool = defineTool({
402
418
  rules: REPORT_AUTHORING_RULES,
403
419
  }),
404
420
  });
421
+ /**
422
+ * Validate a submitted brief, first repairing one that arrived double-encoded.
423
+ *
424
+ * Every other committing tool (`submit_plan`, `submit_synthesis`) puts its real
425
+ * schema on the wire, so a JSON-encoded argument fails the tool's input schema and
426
+ * the agent loop's issue-guided repair pass catches it before `execute` ever runs.
427
+ * `report` rides as `z.unknown()` to keep the ~12k brief schema off the always-on
428
+ * tool surface — which also means a double-encoded brief *satisfies* the input
429
+ * schema, the loop sees no failure to act on, and the repair never fires. That
430
+ * makes this the one committing path where the brief must be repaired locally.
431
+ *
432
+ * When the brief decodes but is then invalid, the issues reported are the decoded
433
+ * value's, not the encoding error's — once the string has been parsed, "expected
434
+ * object, received string" describes a problem this function already solved, and
435
+ * would send the model chasing it instead of the fields actually missing. A brief
436
+ * that does not decode at all keeps its original failure.
437
+ *
438
+ * `validated` is the value the returned issues are addressed to, which is the
439
+ * repaired brief whenever repair happened. An issue's `path` indexes into that
440
+ * value alone, so a diagnostic that walks the path — `hintForZodIssue` — must be
441
+ * handed the same value; walking a decoded field's path into the still-encoded
442
+ * original resolves nothing and silently yields no hint.
443
+ */
444
+ function acceptBrief(report) {
445
+ const direct = ReportBriefSchema.safeParse(report);
446
+ if (direct.success)
447
+ return { parsed: direct, validated: report };
448
+ const repaired = repairToolInput(report, direct.error);
449
+ if (repaired === undefined)
450
+ return { parsed: direct, validated: report };
451
+ return { parsed: ReportBriefSchema.safeParse(repaired), validated: repaired };
452
+ }
405
453
  /**
406
454
  * `submit_report` — takes the composed brief and drives the report iteration.
407
455
  * On a valid brief its behaviour is byte-for-byte the former `iterate_report`:
@@ -412,7 +460,7 @@ export const planReportTool = defineTool({
412
460
  export function createReportSubmitTool(deps) {
413
461
  return defineTool({
414
462
  id: "submit_report",
415
- description: "Submit a composed report brief to build or iterate an HTML/PDF report " +
463
+ description: "Submit a composed report brief to build or iterate an HTML report " +
416
464
  "(the report-builder renders it; it never sees the analysis tree). Call " +
417
465
  "`plan_report` FIRST to get the brief schema + authoring rules, compose " +
418
466
  "the brief, then call this. CREATE: pass `report` (the composed brief), " +
@@ -429,14 +477,14 @@ export function createReportSubmitTool(deps) {
429
477
  // (the validate_plan trade) — never a thrown error.
430
478
  let brief;
431
479
  if (input.report !== undefined) {
432
- const parsed = ReportBriefSchema.safeParse(input.report);
480
+ const { parsed, validated } = acceptBrief(input.report);
433
481
  if (!parsed.success) {
434
482
  return ok({
435
483
  ok: false,
436
484
  issues: parsed.error.issues.map((i) => ({
437
485
  path: i.path.join(".") || "(root)",
438
486
  message: i.message,
439
- hint: hintForZodIssue(i, input.report),
487
+ hint: hintForZodIssue(i, validated),
440
488
  })),
441
489
  hint: "The `report` brief did not match the schema. Call `plan_report` for the full schema, fix the fields named in `issues`, and resubmit.",
442
490
  });
@@ -445,13 +493,79 @@ export function createReportSubmitTool(deps) {
445
493
  }
446
494
  const { resourceId } = scopeResource(ctx.session.scope);
447
495
  const analysisRoot = deps.resolveWorkspaceRoot(resourceId);
448
- // Zod fills the default at parse time but the static type keeps it
449
- // optional — re-apply so the runner sees a concrete value.
450
- const format = input.format ?? "html";
496
+ const format = input.format;
451
497
  const previewId = input.previewId ?? `prv-${randomUUID().slice(0, 8)}`;
452
498
  const previewRootAbs = join(analysisRoot, previewDir(previewId));
453
499
  const assetsDirAbs = join(previewRootAbs, "assets");
454
500
  const metaPathAbs = join(previewRootAbs, PREVIEW_META_FILE);
501
+ // Iteration carries no brief: the builder's entire input is the change
502
+ // text plus the base version's template. A base that is not on disk
503
+ // therefore hands it a change request and nothing to change, and it
504
+ // authors a fresh version while being told the previous template is
505
+ // already in its working directory. Two ways to reach that state — a
506
+ // preview holding no version at all, and a `baseVersion` naming one
507
+ // that was never written — so the base is resolved here exactly as the
508
+ // runner resolves it (`baseVersion ?? latest`) and both are refused.
509
+ // Scoping to iteration is exact, not incidental: the input schema
510
+ // refuses `baseVersion` alongside a brief, so a creation call carries
511
+ // no base to resolve.
512
+ // The check is a directory read and precedes access minting, asset
513
+ // staging, and the builder, so a bad id costs no model turns and
514
+ // leaves no partial state.
515
+ //
516
+ // A version directory implies its `report.html.j2`, so the directory
517
+ // read is the whole check and no template stat is needed. What holds
518
+ // that up is the builder's own finalize gate
519
+ // (`tools/report/submit-report.ts`), which refuses a version whose
520
+ // `report.html.j2` is absent; a version the gate never accepted is
521
+ // never recorded, and the runner removes the directory it created.
522
+ // Relaxing that gate is what would make this check insufficient.
523
+ //
524
+ // The read may also sit outside `withPreviewLock`: nothing in the
525
+ // report path removes an existing version, so a concurrent iteration
526
+ // can only add, and a base seen here is still there when the runner
527
+ // takes the lock. Introducing version deletion makes that ordering
528
+ // load-bearing.
529
+ if (!brief) {
530
+ let versionDirs = [];
531
+ try {
532
+ versionDirs = (await readdir(previewRootAbs, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name);
533
+ }
534
+ catch {
535
+ /* no preview root — indistinguishable from one holding no versions */
536
+ }
537
+ const latestVersion = latestPreviewVersion(versionDirs);
538
+ let reason;
539
+ if (latestVersion === 0) {
540
+ reason =
541
+ `No report exists under previewId '${previewId}', so there is nothing to modify. ` +
542
+ "Create the report first by calling `submit_report` with a `report` brief and no `previewId`, " +
543
+ "or retry with the `previewId` shown on the preview card of the report you meant to change.";
544
+ }
545
+ else if (input.baseVersion !== undefined && !versionDirs.includes(`v${input.baseVersion}`)) {
546
+ reason =
547
+ `Preview '${previewId}' has no version ${input.baseVersion} to branch from — its latest version is ${latestVersion}. ` +
548
+ "Retry with a `baseVersion` that exists, or omit `baseVersion` to branch from the latest version.";
549
+ }
550
+ if (reason !== undefined) {
551
+ await ctx.emit({
552
+ type: "data-report-preview-failed",
553
+ data: {
554
+ id: randomUUID(),
555
+ previewId,
556
+ version: 0,
557
+ reason,
558
+ errorKind: "build",
559
+ },
560
+ });
561
+ return ok({
562
+ previewId,
563
+ version: 0,
564
+ previewPath: "",
565
+ error: reason,
566
+ });
567
+ }
568
+ }
455
569
  // Iteration mode: recover the title from the creation-time meta file
456
570
  // so the data-report-preview part keeps the original title across versions.
457
571
  const existingMeta = brief ? null : await readPreviewMeta(metaPathAbs);
@@ -636,7 +750,7 @@ function buildCreationPrompt(report, format, staged) {
636
750
  out.push("Each section below carries `intent` (why it exists, what to emphasize) " +
637
751
  "and `content` (the data shape to render). You choose the design-system " +
638
752
  "components, layout, alternation, and visual treatment. Stay in the " +
639
- "Inflexa Design Blueprint — base.html.j2, components, theme.css.");
753
+ "Inflexa Design Blueprint — base.html.j2, components.");
640
754
  out.push("");
641
755
  for (let i = 0; i < report.sections.length; i++) {
642
756
  out.push(`### ${i + 1}. ${report.sections[i].title} — type: \`${report.sections[i].type}\``);
@@ -10,7 +10,7 @@
10
10
  * - inspecting an older version (pass `version`)
11
11
  */
12
12
  import { type Tool } from "../define-tool.js";
13
- import type { PreviewPublisher } from "./preview-publisher.js";
13
+ import { type PreviewPublisher } from "./preview-publisher.js";
14
14
  /** Shared, mutable URL cell so mint and preview-snapshot share a cached URL. */
15
15
  export interface PreviewUrlCell {
16
16
  url: string | undefined;
@@ -12,13 +12,15 @@
12
12
  import { ok } from "neverthrow";
13
13
  import { z } from "zod";
14
14
  import { defineTool } from "../define-tool.js";
15
+ import { describeMintFailure } from "./preview-publisher.js";
15
16
  export function createMintPreviewUrlTool(state) {
16
17
  return defineTool({
17
18
  id: "mint_preview_url",
18
- description: "Mint a fresh preview URL for the report. Returns a content-server " +
19
- "URL with embedded auth token (15-min TTL). Use this only when you " +
20
- "need a URL for direct browser navigation — preview_snapshot handles " +
21
- "the common case automatically.",
19
+ description: "Mint a fresh preview URL for the report. Returns a time-limited " +
20
+ "content-server URL with an embedded auth token, alongside the " +
21
+ "`expiresAt` it is good until. Use this only when you need a URL for " +
22
+ "direct browser navigation — preview_snapshot handles the common case " +
23
+ "automatically.",
22
24
  inputSchema: z.object({
23
25
  version: z.number().optional().describe("Version to mint for. Defaults to the iteration's current version."),
24
26
  }),
@@ -28,7 +30,7 @@ export function createMintPreviewUrlTool(state) {
28
30
  if (!result.ok) {
29
31
  return ok({
30
32
  ok: false,
31
- error: `preview-access mint failed: status=${result.status} ${result.error.message ?? ""}`.trim(),
33
+ error: describeMintFailure(result),
32
34
  });
33
35
  }
34
36
  const url = `${result.data.baseUrl.replace(/\/?$/, "/")}v${version}/index.html?t=${result.data.token}`;
@@ -24,6 +24,22 @@ export type PreviewMintResult = {
24
24
  message?: string;
25
25
  };
26
26
  };
27
+ /** The not-ok arm of the seam's result — what a failed mint is allowed to carry. */
28
+ export type PreviewMintFailure = Extract<PreviewMintResult, {
29
+ ok: false;
30
+ }>;
31
+ /**
32
+ * Renders a failed mint as the line an agent reads back from a preview tool.
33
+ *
34
+ * The failure shape is sparse by design: a realization with no HTTP transport
35
+ * behind it supplies neither a status nor, necessarily, a message. Naming a
36
+ * field the seam left unset would put the literal `status=undefined` in front
37
+ * of the model, so the message carries only what actually arrived. This lives
38
+ * beside the type because its whole job is to honour that type's optionality —
39
+ * every tool holding the seam owes the same message, and one of them owning
40
+ * the wording would make the others depend on a peer for a seam-wide concern.
41
+ */
42
+ export declare function describeMintFailure(failure: PreviewMintFailure): string;
27
43
  export interface PreviewPublisher {
28
44
  mintPreviewAccess(resourceId: string, previewId: string): Promise<PreviewMintResult>;
29
45
  }
@@ -10,6 +10,26 @@
10
10
  * returns not-ok, so `preview_snapshot` short-circuits before touching
11
11
  * Chrome and the report still builds (submit_report is the only gate).
12
12
  */
13
+ /**
14
+ * Renders a failed mint as the line an agent reads back from a preview tool.
15
+ *
16
+ * The failure shape is sparse by design: a realization with no HTTP transport
17
+ * behind it supplies neither a status nor, necessarily, a message. Naming a
18
+ * field the seam left unset would put the literal `status=undefined` in front
19
+ * of the model, so the message carries only what actually arrived. This lives
20
+ * beside the type because its whole job is to honour that type's optionality —
21
+ * every tool holding the seam owes the same message, and one of them owning
22
+ * the wording would make the others depend on a peer for a seam-wide concern.
23
+ */
24
+ export function describeMintFailure(failure) {
25
+ const detail = [];
26
+ if (failure.status !== undefined)
27
+ detail.push(`status=${failure.status}`);
28
+ const message = failure.error.message?.trim();
29
+ if (message)
30
+ detail.push(message);
31
+ return detail.length > 0 ? `preview-access mint failed: ${detail.join(" ")}` : "preview-access mint failed";
32
+ }
13
33
  /**
14
34
  * Local default — no hosted preview surface. Returns not-ok so the preview
15
35
  * tools surface a clear "unavailable" message and short-circuit before any
@@ -10,10 +10,16 @@
10
10
  * Returned screenshot is base64-encoded PNG bytes; the agent can inspect
11
11
  * console errors / failed requests to decide whether to fix the template
12
12
  * or move on to submit_report.
13
+ *
14
+ * A seam that cannot mint is also reported through the injected `Logger`:
15
+ * this tool is the build's only visual verification, so an unavailable seam
16
+ * means that verification silently did not happen — a condition an operator
17
+ * has to be able to see without reading the model transcript.
13
18
  */
14
19
  import { type Tool } from "../define-tool.js";
15
20
  import { type ChromeConfig } from "../../lib/chrome.js";
16
- import type { PreviewPublisher } from "./preview-publisher.js";
21
+ import type { Logger } from "../../lib/logger.js";
22
+ import { type PreviewPublisher } from "./preview-publisher.js";
17
23
  import type { PreviewUrlCell } from "./mint-preview-url.js";
18
24
  export interface PreviewSnapshotToolState {
19
25
  readonly resourceId: string;
@@ -22,5 +28,7 @@ export interface PreviewSnapshotToolState {
22
28
  readonly previews: PreviewPublisher;
23
29
  readonly urlCell: PreviewUrlCell;
24
30
  readonly chrome: ChromeConfig;
31
+ /** Operational logging seam; omitted falls back to no-op. */
32
+ readonly logger?: Logger;
25
33
  }
26
34
  export declare function createPreviewSnapshotTool(state: PreviewSnapshotToolState): Tool;
@@ -10,15 +10,23 @@
10
10
  * Returned screenshot is base64-encoded PNG bytes; the agent can inspect
11
11
  * console errors / failed requests to decide whether to fix the template
12
12
  * or move on to submit_report.
13
+ *
14
+ * A seam that cannot mint is also reported through the injected `Logger`:
15
+ * this tool is the build's only visual verification, so an unavailable seam
16
+ * means that verification silently did not happen — a condition an operator
17
+ * has to be able to see without reading the model transcript.
13
18
  */
14
19
  import { ok } from "neverthrow";
15
20
  import { z } from "zod";
16
21
  import { defineTool } from "../define-tool.js";
17
22
  import { withPage } from "../../lib/chrome.js";
23
+ import { createNoopLogger } from "../../lib/console-logger.js";
24
+ import { describeMintFailure } from "./preview-publisher.js";
18
25
  const NAV_TIMEOUT_MS = 20_000;
19
26
  const READY_TIMEOUT_MS = 8_000;
20
27
  const WAIT_MS_CAP = 30_000;
21
28
  export function createPreviewSnapshotTool(state) {
29
+ const logger = (state.logger ?? createNoopLogger()).named("preview-snapshot").with({ previewId: state.previewId });
22
30
  return defineTool({
23
31
  id: "preview_snapshot",
24
32
  description: "Render the report in a real headless browser and report what you " +
@@ -48,11 +56,17 @@ export function createPreviewSnapshotTool(state) {
48
56
  if (!url) {
49
57
  const result = await state.previews.mintPreviewAccess(state.resourceId, state.previewId);
50
58
  if (!result.ok) {
59
+ const reason = result.error.message?.trim();
60
+ logger.warn("preview access unavailable — visual verification did not run", {
61
+ version: state.currentVersion,
62
+ ...(result.status !== undefined ? { status: result.status } : {}),
63
+ ...(reason ? { reason } : {}),
64
+ });
51
65
  return ok({
52
66
  ok: false,
53
67
  consoleErrors: [],
54
68
  failedRequests: [],
55
- error: `preview-access mint failed: status=${result.status} ${result.error.message ?? ""}`.trim(),
69
+ error: describeMintFailure(result),
56
70
  });
57
71
  }
58
72
  url = `${result.data.baseUrl.replace(/\/?$/, "/")}v${state.currentVersion}/index.html?t=${result.data.token}`;
@@ -3,6 +3,7 @@
3
3
  * `data-report-preview` until this tool writes the success outcome.
4
4
  *
5
5
  * Checks:
6
+ * - report.html.j2 exists at versionDir/ — the report itself
6
7
  * - index.html exists at versionDir/ with size > 0
7
8
  * - no unrendered `{{ … }}` or `{% … %}` left in the output
8
9
  * - all referenced asset paths resolve to files on disk