From 090fff7a1ae5b68eb57f5fc5d101f03ed77d93d6 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Sat, 1 Aug 2026 15:03:48 +0800 Subject: [PATCH 1/2] feat(reports): add Cursor Canvas report route Render the complete Better Harness report inside Cursor with the public `cursor/canvas` SDK, and surface any workspace-scoped native Context Usage snapshot as the Context Window section of that report. Cursor session scans now project bounded token, category, and item metadata from Cursor's own `context-usage-*.canvas.data.json` files while omitting raw item text, and a missing snapshot stays unobserved instead of being reported as zero usage. `harness render --mode cursor-canvas` writes a deterministic `findings.json`, `canvas.json`, and `report.canvas.tsx` bundle, then validates the split data contract, module boundaries, required report sections, public action bindings, and the final TSX transform. This also moves Cursor's default output route from portable HTML to the host-native Canvas, so the adapter matrix, report routing, skill routing, and bilingual entrypoints are updated to match. The intent, acceptance scenarios, and risks are recorded in docs/specs/2026-08-01-cursor-canvas-report.md. Validated with npm test (1051 passing), npm run pack:verify, and a real Cursor scan followed by a validated cursor-canvas render against an observed native snapshot. Co-authored-by: QoderAI --- README.md | 6 +- README.zh-CN.md | 2 +- docs/adapters/README.md | 6 +- docs/adrs/directory-structure.md | 1 + docs/docs/hosts/adapter-matrix.md | 6 +- docs/docs/your-first-report.md | 6 +- .../current/hosts/adapter-matrix.md | 6 +- .../current/your-first-report.md | 4 +- docs/specs/2026-08-01-cursor-canvas-report.md | 121 +++++ scripts/better-harness-cli/registry.mjs | 4 +- .../harness-analysis/evidence-bundle/cli.mjs | 2 +- scripts/harness-analysis/render-report.mjs | 39 +- .../renderers/cursor-canvas.mjs | 32 ++ .../harness-analysis/report-data-schema.mjs | 2 +- scripts/harness-analysis/report-run.mjs | 8 +- scripts/harness-analysis/task-loop-report.mjs | 99 +++- scripts/harness-analysis/task-loop-source.mjs | 3 + .../validate-cursor-canvas.mjs | 98 ++++ scripts/session-analysis/platforms/cursor.mjs | 168 ++++++- skills/better-harness/SKILL.md | 13 +- .../canvas/better-harness-cursor.canvas.tsx | 463 ++++++++++++++++++ templates/reporting/cursor-canvas.md | 69 +++ templates/reporting/routing.md | 3 +- test/agent-customize-architecture.test.mjs | 3 +- test/better-harness-skill.test.mjs | 10 +- test/docs-entrypoints.test.mjs | 6 +- test/harness-report-run.test.mjs | 4 +- test/scripts-refactor-contract.test.mjs | 6 +- test/style-templates.test.mjs | 3 +- 29 files changed, 1139 insertions(+), 54 deletions(-) create mode 100644 docs/specs/2026-08-01-cursor-canvas-report.md create mode 100644 scripts/harness-analysis/renderers/cursor-canvas.mjs create mode 100644 scripts/harness-analysis/validate-cursor-canvas.mjs create mode 100644 templates/canvas/better-harness-cursor.canvas.tsx create mode 100644 templates/reporting/cursor-canvas.md diff --git a/README.md b/README.md index c5434d1..cd3fb44 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,9 @@ Quickstart; see the [public Host Adapter Matrix](docs/docs/hosts/adapter-matrix. for their explicit boundaries. Better Harness scopes behavior claims to relevant Task Episodes and the -surrounding project mechanisms. Qoder produces a Canvas report; Claude Code, -Codex, Cursor, Qwen Code, and GitHub Copilot produce self-contained HTML with -paired Markdown. Missing or partial evidence remains explicit. See the +surrounding project mechanisms. Qoder and Cursor produce host-native Canvas +reports; Claude Code, Codex, Qwen Code, and GitHub Copilot produce +self-contained HTML with paired Markdown. Missing or partial evidence remains explicit. See the [Host Adapter Matrix](docs/adapters/README.md) for current coverage and output differences. diff --git a/README.zh-CN.md b/README.zh-CN.md index 599ff2e..14d83e5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -38,7 +38,7 @@ [公开宿主适配矩阵](docs/docs/hosts/adapter-matrix.md)。 Better Harness 会将行为断言限定在相关的任务过程片段(Task Episode)及其周边项目机制内。 -Qoder 生成 Canvas 报告;Claude Code、Codex、Cursor、Qwen Code 和 GitHub Copilot 生成自包含的 HTML 报告及配套 Markdown。 +Qoder 与 Cursor 生成宿主原生 Canvas 报告;Claude Code、Codex、Qwen Code 和 GitHub Copilot 生成自包含的 HTML 报告及配套 Markdown。 缺失或不完整的证据会被明确标注。有关当前覆盖范围和输出差异,请参阅 [宿主适配器矩阵](docs/adapters/README.md)。 diff --git a/docs/adapters/README.md b/docs/adapters/README.md index 3fa06d2..8f6da02 100644 --- a/docs/adapters/README.md +++ b/docs/adapters/README.md @@ -30,7 +30,7 @@ package through the `pi` manifest in `package.json`. | Claude Code | Analysis-capable source-local host | `.claude-plugin/` | `scripts/agent-customize/providers/claude.mjs` | `scripts/session-analysis/platforms/claude.mjs` | self-contained HTML + Markdown | `.claude` + `CLAUDE.md` + Plugin assets | `claude plugin validate --strict .` -> isolated install/discovery -> configured-asset baseline -> validated `html` render | | Codex | Analysis-capable source-local host | `.codex-plugin/` | `scripts/agent-customize/providers/codex.mjs` | `scripts/session-analysis/platforms/codex.mjs` | self-contained HTML + Markdown | `.codex` + `.agents` + `AGENTS.md` | `harness prepare --platform codex` -> finalize with `html-report` validation | | Qoder | First-class product host | `.qoder-plugin/` | `scripts/agent-customize/providers/qoder.mjs` | `scripts/session-analysis/platforms/qoder.mjs` | `better-harness` | `.qoder/rules` + `AGENTS.md` + output templates | `better-harness harness render --mode qoder-canvas --validate` | -| Cursor | Analysis-capable source-local host | `.cursor-plugin/` | `scripts/agent-customize/providers/cursor.mjs` | `scripts/session-analysis/platforms/cursor.mjs` | self-contained HTML + Markdown | `.cursor` + `.codex` compatibility + `AGENTS.md` | `agent --plugin-dir . --mode ask --print` -> Cursor evidence bundle -> validated `html` render | +| Cursor | Canvas-capable source-local host | `.cursor-plugin/` | `scripts/agent-customize/providers/cursor.mjs` | `scripts/session-analysis/platforms/cursor.mjs` | `cursor-canvas` | `.cursor` + `.codex` compatibility + `AGENTS.md` | `agent --plugin-dir . --mode ask --print` -> Cursor evidence bundle -> validated `cursor-canvas` render | | Qwen Code | Analysis-capable source-local host | `qwen-extension.json` | `scripts/agent-customize/providers/qwen.mjs` | `scripts/session-analysis/platforms/qwen.mjs` | self-contained HTML + Markdown | `.qwen` + `QWEN.md` + `AGENTS.md` | `harness prepare --platform qwen` -> finalize with `html-report` validation | | GitHub Copilot | Analysis-capable source-local host | `.github/plugin/` | `scripts/agent-customize/providers/copilot.mjs` | `scripts/session-analysis/platforms/copilot.mjs` | self-contained HTML + Markdown | `.github` + `AGENTS.md` + `~/.copilot` | `copilot plugin marketplace add .` -> `copilot plugin install better-harness@better-harness` -> configured-asset baseline -> validated `html` render | | Pi | Analysis-capable source-local host | `pi` manifest in `package.json` | `scripts/agent-customize/providers/pi.mjs` | `scripts/session-analysis/platforms/pi.mjs` | self-contained HTML + Markdown | `.pi` + `.agents` + `AGENTS.md` | `pi install ` or `pi -e ` -> `/better-harness` prompt template -> validated `html` render | @@ -116,7 +116,9 @@ Canonical templates live under `templates/reporting/`. - `qoder-canvas.md`: Qoder Canvas output contract, covering renderer-owned `findings.json`, Canvas-only `canvas.json`, and `report.canvas.tsx`. -- `html-visual.md`: portable Claude Code/Codex/Cursor/Qwen/Copilot/Pi/WorkBuddy visual output contract, covering +- `cursor-canvas.md`: Cursor Canvas output contract, covering the complete + report, native Context Usage projection, and public IDE actions. +- `html-visual.md`: portable Claude Code/Codex/Qwen/Copilot/Pi/WorkBuddy visual output contract, covering `findings.json`, `report.md`, and `report.html`. - Markdown-only output has no visual companion. diff --git a/docs/adrs/directory-structure.md b/docs/adrs/directory-structure.md index 622f9c2..2faa288 100644 --- a/docs/adrs/directory-structure.md +++ b/docs/adrs/directory-structure.md @@ -146,6 +146,7 @@ templates/ # [active] runtime-selected contracts routing.md # [active] report/style/output route selection report-structure.md # [active] Markdown report skeleton qoder-canvas.md # [active] Qoder Canvas output contract + cursor-canvas.md # [active] Cursor Canvas output contract html-visual.md # [active] HTML visual contract style/ # visual grammar and style routing components/ # [target] reusable component profiles, 2+ consumers diff --git a/docs/docs/hosts/adapter-matrix.md b/docs/docs/hosts/adapter-matrix.md index 4880b06..d4561e7 100644 --- a/docs/docs/hosts/adapter-matrix.md +++ b/docs/docs/hosts/adapter-matrix.md @@ -26,7 +26,7 @@ remains the complete capability-level source of truth. | Qoder | Verified Quickstart | First-class product host | `.qoder-plugin/` | Qoder sessions | Qoder Canvas report | | Claude Code | Verified Quickstart | Analysis-capable source-local host | `.claude-plugin/` | Workspace-matching local Claude transcripts when present | Self-contained HTML + Markdown | | Codex | Verified Quickstart | Analysis-capable source-local host | `.codex-plugin/` | Codex sessions | Self-contained HTML + Markdown | -| Cursor | Verified Quickstart | Analysis-capable source-local host | `.cursor-plugin/` | Workspace-matched transcripts, metadata, and audit logs; partial coverage stays explicit | Self-contained HTML + Markdown | +| Cursor | Verified Quickstart | Canvas-capable source-local host | `.cursor-plugin/` | Workspace-matched transcripts, metadata, audit logs, and optional native Context Usage snapshots; partial coverage stays explicit | Cursor Canvas report | | Qwen Code | Verified Quickstart | Analysis-capable source-local host | `qwen-extension.json` | Workspace-matching local Qwen transcripts when present | Self-contained HTML + Markdown | | GitHub Copilot | Verified Quickstart | Analysis-capable source-local host | `.github/plugin/` | Workspace-matched Copilot CLI transcripts; partial coverage stays explicit | Self-contained HTML + Markdown | | Pi | Adapter support | Analysis-capable source-local host | `pi` manifest in `package.json` | Workspace-matching local Pi sessions | Self-contained HTML + Markdown | @@ -42,7 +42,9 @@ source-local. - **Qoder Canvas** — renderer-owned `findings.json`, Canvas-only `canvas.json`, and `report.canvas.tsx`. -- **HTML visual** — portable Claude Code/Codex/Cursor/Qwen/Copilot/Pi/WorkBuddy contract +- **Cursor Canvas** — the same complete report contract rendered with + `cursor/canvas`, native Context Window evidence, and IDE actions. +- **HTML visual** — portable Claude Code/Codex/Qwen/Copilot/Pi/WorkBuddy contract covering `findings.json`, `report.md`, and a self-contained `report.html` (see the [sample report](pathname:///demo/better-harness-report/)). - **Markdown-only** — no visual companion. diff --git a/docs/docs/your-first-report.md b/docs/docs/your-first-report.md index 9985ff7..7f18f3d 100644 --- a/docs/docs/your-first-report.md +++ b/docs/docs/your-first-report.md @@ -19,9 +19,9 @@ your host's **Verify installation** section—the syntax is host-specific: does not claim an unverified slash-command alias. Better Harness scopes behavior claims to relevant Task Episodes and the -surrounding project mechanisms. Qoder produces a Canvas report; Claude Code, -Codex, Cursor, Qwen Code, and GitHub Copilot produce self-contained HTML with -paired Markdown. Missing or partial evidence remains explicit. +surrounding project mechanisms. Qoder and Cursor produce host-native Canvas +reports; Claude Code, Codex, Qwen Code, and GitHub Copilot produce +self-contained HTML with paired Markdown. Missing or partial evidence remains explicit. See the [sample report](pathname:///demo/better-harness-report/) for what the HTML output looks like. diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md index 7a9298a..e063bf0 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md @@ -24,7 +24,7 @@ Better Harness 当前声明了八个能力层宿主适配器,其中六个已 | Qoder | 已验证快速开始 | 一等产品宿主 | `.qoder-plugin/` | Qoder 会话 | Qoder Canvas 报告 | | Claude Code | 已验证快速开始 | 具备分析能力的源码本地宿主 | `.claude-plugin/` | 匹配当前工作区的本地 Claude 转录(存在时) | 自包含 HTML + Markdown | | Codex | 已验证快速开始 | 具备分析能力的源码本地宿主 | `.codex-plugin/` | Codex 会话 | 自包含 HTML + Markdown | -| Cursor | 已验证快速开始 | 具备分析能力的源码本地宿主 | `.cursor-plugin/` | 工作区匹配的转录、元数据和审计日志;部分覆盖保持显式标注 | 自包含 HTML + Markdown | +| Cursor | 已验证快速开始 | 支持 Canvas 的源码本地宿主 | `.cursor-plugin/` | 工作区匹配的转录、元数据、审计日志和可选原生 Context Usage 快照;部分覆盖保持显式标注 | Cursor Canvas 报告 | | Qwen Code | 已验证快速开始 | 具备分析能力的源码本地宿主 | `qwen-extension.json` | 匹配当前工作区的本地 Qwen 转录(存在时) | 自包含 HTML + Markdown | | GitHub Copilot | 已验证快速开始 | 具备分析能力的源码本地宿主 | `.github/plugin/` | 工作区匹配的 Copilot CLI 转录;部分覆盖保持显式标注 | 自包含 HTML + Markdown | | Pi | 适配器支持 | 具备分析能力的源码本地宿主 | `package.json` 中的 `pi` manifest | 匹配当前工作区的本地 Pi 会话 | 自包含 HTML + Markdown | @@ -38,7 +38,9 @@ Better Harness 当前声明了八个能力层宿主适配器,其中六个已 - **Qoder Canvas** —— 渲染器负责的 `findings.json`、仅 Canvas 使用的 `canvas.json` 和 `report.canvas.tsx`。 -- **HTML 可视化** —— 面向 Claude Code/Codex/Cursor/Qwen/Copilot/Pi/WorkBuddy 的可移植契约,覆盖 +- **Cursor Canvas** —— 使用 `cursor/canvas`、原生 Context Window 证据和 + IDE actions 渲染同一份完整报告契约。 +- **HTML 可视化** —— 面向 Claude Code/Codex/Qwen/Copilot/Pi/WorkBuddy 的可移植契约,覆盖 `findings.json`、`report.md` 和自包含的 `report.html` (见[示例报告](pathname:///demo/better-harness-report/))。 - **纯 Markdown** —— 无视觉版本。 diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/your-first-report.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/your-first-report.md index 4049882..48c9572 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/your-first-report.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/your-first-report.md @@ -17,8 +17,8 @@ sidebar_position: 3 再让 Copilot 使用该 Skill 完成分析。本站不会声称未经验证的斜杠命令别名。 Better Harness 会把行为论断限定在相关的 Task Episode 和周边的项目机制内。 -Qoder 产出 Canvas 报告;Claude Code、Codex、Cursor、Qwen Code 和 GitHub Copilot -产出自包含 HTML 并配套 Markdown。缺失或不完整的证据保持显式标注。 +Qoder 与 Cursor 产出宿主原生 Canvas 报告;Claude Code、Codex、Qwen Code 和 +GitHub Copilot 产出自包含 HTML 并配套 Markdown。缺失或不完整的证据保持显式标注。 想了解 HTML 输出的样子,可以查看 [示例报告](pathname:///demo/better-harness-report/)。 diff --git a/docs/specs/2026-08-01-cursor-canvas-report.md b/docs/specs/2026-08-01-cursor-canvas-report.md new file mode 100644 index 0000000..93ab375 --- /dev/null +++ b/docs/specs/2026-08-01-cursor-canvas-report.md @@ -0,0 +1,121 @@ +# Render complete Better Harness reports in Cursor Canvas + +## Traceability + +- Spec ID: `cursor-canvas-report` +- Status: Implemented + +## Intent + +Give Cursor users the same durable, complete Better Harness report experience +that Qoder users receive, while using Cursor's public Canvas SDK and native IDE +actions. Cursor session scans should automatically retain any workspace-scoped +Context Usage snapshot that Cursor has already materialized, and the report +renderer should present that evidence as one section of the full report rather +than as a standalone Context Usage page. + +The existing Qoder Canvas, Markdown, and HTML routes remain unchanged. JSON +analysis remains parser-safe and does not acquire an implicit file-write side +effect. + +## Acceptance Scenarios + +- AC-1: A Cursor session analysis for a workspace discovers existing native + `context-usage-*.canvas.data.json` snapshots only below that workspace's + Cursor project directory. It projects bounded token/category/item metadata, + omits raw item text, and reports missing snapshots as unobserved rather than + as zero usage. +- AC-2: `harness analyze --platform cursor --format json` includes the bounded + Context Usage projection in its exact summary facts when observed, without + changing Qoder, Markdown, or HTML behavior and without writing files unless + an explicit output option is supplied. +- AC-3: `harness render --mode cursor-canvas` accepts the same complete reviewed + `summary + findings` contract as Qoder Canvas and writes a deterministic + `findings.json`, `canvas.json`, and `report.canvas.tsx` bundle. +- AC-4: The generated Cursor Canvas renders the complete Better Harness reader + flow: introduction and Fluency dimensions, project usage, AI Agent Practice, + Context Window, prioritized findings, suggestions, and evidence/methodology. + Context Window remains visible as unavailable when no native snapshot exists. +- AC-5: The Canvas uses only the public `cursor/canvas` surface. Finding handoff + dispatches `newComposerChat`, safe file-backed evidence dispatches `openFile` + with an optional selection, and an observed source conversation dispatches + `openAgent`. Action payloads are transport data and raw session ids are not + printed as reader copy. +- AC-6: Render-time validation proves the final TSX parses as TSX, imports only + the public `cursor/canvas` surface, contains the complete report + sections, and binds the public Canvas actions. Validation does not depend on + Qoder's runtime declarations. +- AC-7: A bounded real-workspace smoke completes the Cursor scan and then + produces a validated `cursor-canvas` bundle from the resulting report data. + The generated TSX can be transformed successfully with the repository's + shipped TSX transform. + +## Non-goals + +- Reproduce Cursor's proprietary Context Usage implementation or copy its + generated template. +- Decode Cursor `store.db`, synthesize missing system/tool definitions, or claim + token attribution that Cursor did not materialize. +- Make Canvas generation a side effect of every JSON command. +- Refactor the existing Qoder Canvas template in the same change. +- Add comprehensive unit coverage before the first end-to-end render smoke; + focused contract tests may follow once the runnable path is proven. + +## Plan and Tasks + +1. Extend the Cursor session provider with an optional, workspace-qualified + native Context Usage source and a privacy-bounded projection. +2. Thread Context Usage through the task-loop source, summary-facts projection, + complete report contract, and Canvas split/merge path. +3. Add the `cursor-canvas` renderer and a standalone Cursor SDK template that + embeds the merged complete report while retaining the sibling JSON artifacts + as the durable data contract. +4. Add Cursor-specific validation using the existing module-boundary scanner + and TSX transform, then register the mode in report routing and CLI help. +5. Run a real Cursor scan followed by a complete report projection/render and + inspect the generated artifacts and transformed module. + +The Cursor renderer remains capability-owned under `scripts/harness-analysis/` +and the template remains under `templates/canvas/`; the `.cursor-plugin` shell +does not own report judgment or schema. + +## Test and Review Evidence + +- AC-1/AC-2: Run `node scripts/session-analysis.mjs insights --platform cursor + --workspace --format json` and `node scripts/better-harness.mjs + harness analyze --platform cursor --workspace --format json`. + Inspect the bounded projection and confirm no raw item `text` is retained. +- AC-3/AC-4/AC-5: Render a complete Agent Work Loop report with `--mode + cursor-canvas`; inspect all three artifacts and the generated action bindings. +- AC-6: Run the Cursor Canvas validator against the final run directory and + transform `report.canvas.tsx` with `transformCanvasSource`. +- AC-7: Use a temporary run directory for the real scan/render smoke and retain + the command output plus artifact inventory as review evidence. +- Risk: Cursor's native snapshot schema and public Canvas SDK may change. Fail + closed on malformed snapshots, retain an explicit schema/status boundary, + and avoid private command ids or internal protobuf imports. +- Risk: native snapshot items may contain raw prompt or tool text. Persist only + labels, token/character counts, hierarchy, and safe file references; never + copy the native `text` field into analysis or report artifacts. +- Risk: Cursor actions can expose local identity or files. Keep ids as hidden + action transport, render safe paths only when already admitted by the report, + and omit unavailable actions instead of inventing targets. + +## Implementation Evidence + +- AC-1/AC-2: Real Cursor scans covered both an observed native snapshot + (`56,860 / 300,000`, seven categories, 119 bounded items) and the explicit + unobserved Better Harness workspace state; projected items retained no raw + `text` and did not print absolute labels. +- AC-3/AC-4/AC-5/AC-6: `harness render --mode cursor-canvas --validate` wrote + exactly `findings.json`, `canvas.json`, and `report.canvas.tsx`; validation + passed data, module-boundary, complete-section, action-binding, and TSX + transform checks with `cursor/canvas` as the sole import. +- AC-7: Cursor 3.13.10 opened the generated managed Canvas and rendered the + overview, strengths, Fluency chart, AI Agent Practice, Context Window, + findings boundary, and evidence/methodology sections. The unavailable Context + Window state remained explicit for the Better Harness workspace. +- Repository verification: `npm test` passed 1,051 tests, `npm run pack:verify` + passed both npm and runtime ZIP inventories, the generated doc-link graph was + current, and the local preview returned `ok` from `/health` plus a transformed + `/canvas-module.js`. diff --git a/scripts/better-harness-cli/registry.mjs b/scripts/better-harness-cli/registry.mjs index 5414bec..2a589ca 100644 --- a/scripts/better-harness-cli/registry.mjs +++ b/scripts/better-harness-cli/registry.mjs @@ -181,7 +181,7 @@ const COMMANDS = [ audience: "workflow", script: "harness-analysis/report-run.mjs", summary: "Return a neutral, budgeted Harness evidence brief.", - description: "Scan evidence read-only and return bounded natural text for AI interpretation, plus exact report summary facts in JSON mode; explicit Qoder canvas-out initializes them and replace-canvas refreshes only that authorized path.", + description: "Scan evidence read-only and return bounded natural text for AI interpretation, plus exact report summary facts in JSON mode; explicit Qoder or Cursor canvas-out initializes them and replace-canvas refreshes only that authorized path.", }, { name: "checkup", @@ -216,7 +216,7 @@ const COMMANDS = [ audience: "advanced", script: "harness-analysis/render-report.mjs", summary: "Render reviewed findings data into report artifacts.", - description: "Render reviewed findings.json data into qoder-canvas, markdown, or html, and optionally run the selected validators.", + description: "Render reviewed findings.json data into qoder-canvas, cursor-canvas, markdown, or html, and optionally run the selected validators.", }, { name: "preview-canvas", diff --git a/scripts/harness-analysis/evidence-bundle/cli.mjs b/scripts/harness-analysis/evidence-bundle/cli.mjs index b1569d5..0184243 100644 --- a/scripts/harness-analysis/evidence-bundle/cli.mjs +++ b/scripts/harness-analysis/evidence-bundle/cli.mjs @@ -24,7 +24,7 @@ Options: --claude-home Claude config root override --claude-state Claude state-file override --workbuddy-home WorkBuddy data root override - --canvas-out With Qoder, initialize canvas.json from lead facts + --canvas-out With Qoder or Cursor, initialize canvas.json from lead facts --replace-canvas Replace that canvas.json when explicitly authorized --format json JSON only --json Emit JSON diff --git a/scripts/harness-analysis/render-report.mjs b/scripts/harness-analysis/render-report.mjs index 5d1fa80..5109b0f 100644 --- a/scripts/harness-analysis/render-report.mjs +++ b/scripts/harness-analysis/render-report.mjs @@ -10,6 +10,7 @@ import { canvasArtifactsFromReportData, readJsonFile, findingsJsonFromReportData import { repairFindingsJsonData } from "./repair-findings-json.mjs"; import { allocateRunDir } from "./run-dir.mjs"; import { evaluateFindingsJson, validateHarnessCanvasArtifacts } from "./validate-canvas.mjs"; +import { validateCursorCanvasArtifacts } from "./validate-cursor-canvas.mjs"; import { resolveWorkspaceTopology } from "../workspace-topology/index.mjs"; import { evaluateHtmlReport, renderHtml } from "./renderers/html.mjs"; import { renderMarkdown } from "./renderers/markdown.mjs"; @@ -28,13 +29,19 @@ import { QODER_CANVAS_FILE, renderQoderCanvas, } from "./renderers/qoder-canvas.mjs"; +import { + CURSOR_CANVAS_FINDINGS_FILE, + CURSOR_CANVAS_DATA_FILE, + CURSOR_CANVAS_FILE, + renderCursorCanvas, +} from "./renderers/cursor-canvas.mjs"; function filesystemPathIdentity(value) { const normalized = path.normalize(value); return process.platform === "win32" ? normalized.toLowerCase() : normalized; } -const HELP = `Usage: better-harness harness render (--source | --findings ) --mode --out --target [options] +const HELP = `Usage: better-harness harness render (--source | --findings ) --mode --out --target [options] Render reviewed findings data into deterministic report artifacts. @@ -42,8 +49,8 @@ Options: --findings Reviewed findings JSON with top-level summary and findings --canvas Explicit legacy canvas.json companion for a split Agent Work Loop bundle --source Reviewed Agent Work Loop report source; projection is performed in memory - --mode qoder-canvas, markdown, or html (default: qoder-canvas) - --out Output root (default: .qoder/better-harness) + --mode qoder-canvas, cursor-canvas, markdown, or html (default: qoder-canvas) + --out Output root (default: .qoder/better-harness, or .cursor/better-harness for cursor-canvas) --run-dir Run directory: relative values resolve below --out; absolute values remain exact --target Target project path used for run-directory slug --language en or zh-CN (default: input summary.locale, then en) @@ -55,7 +62,7 @@ Options: `; function parseArgs(argv) { - const options = { mode: "qoder-canvas", out: ".qoder/better-harness", validate: false, json: false }; + const options = { mode: "qoder-canvas", validate: false, json: false }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "-h" || arg === "--help") { @@ -73,6 +80,7 @@ function parseArgs(argv) { throw Object.assign(new Error(`Unknown argument: ${arg}`), { code: "UNKNOWN_ARGUMENT" }); } } + options.out ??= options.mode === "cursor-canvas" ? ".cursor/better-harness" : ".qoder/better-harness"; return options; } @@ -97,7 +105,7 @@ function artifact(name, runDir) { } function implicitAnalyzerCanvasPath(options, findingsPath) { - if (options.canvas || options.mode !== "qoder-canvas") { + if (options.canvas || !new Set(["qoder-canvas", "cursor-canvas"]).has(options.mode)) { return null; } const candidatePath = path.join(path.dirname(findingsPath), QODER_CANVAS_DATA_FILE); @@ -167,6 +175,17 @@ async function writeArtifacts({ reportData, artifactDir, runDir }) { await writeFile(path.join(artifactDir, name), content); artifacts.push(artifact(name, runDir)); } + } else if (reportData.mode === "cursor-canvas") { + const output = canvasArtifactsFromReportData(reportData); + await writeJson(path.join(artifactDir, CURSOR_CANVAS_FINDINGS_FILE), output.findings); + artifacts.push(artifact(CURSOR_CANVAS_FINDINGS_FILE, runDir)); + await writeJson(path.join(artifactDir, CURSOR_CANVAS_DATA_FILE), output.canvas); + artifacts.push(artifact(CURSOR_CANVAS_DATA_FILE, runDir)); + const rendered = renderCursorCanvas(reportData); + for (const [name, content] of Object.entries(rendered)) { + await writeFile(path.join(artifactDir, name), content); + artifacts.push(artifact(name, runDir)); + } } else if (reportData.mode === "markdown") { const findingsJson = findingsJsonFromReportData(reportData); await writeJson(path.join(artifactDir, "findings.json"), findingsJson); @@ -230,6 +249,13 @@ async function validateArtifacts({ reportData, artifactDir, runDir, artifacts, o preview: false, }); checks.push(...result.checks); + } else if (reportData.mode === "cursor-canvas") { + const result = await validateCursorCanvasArtifacts({ + canvasPath: path.join(artifactDir, CURSOR_CANVAS_FILE), + findingsPath: path.join(artifactDir, CURSOR_CANVAS_FINDINGS_FILE), + canvasDataPath: path.join(artifactDir, CURSOR_CANVAS_DATA_FILE), + }); + checks.push(...result.checks); } else { const findingsPath = path.join(artifactDir, "findings.json"); const findingsText = readFileSync(findingsPath, "utf8"); @@ -265,6 +291,9 @@ function artifactNamesForMode(mode) { if (mode === "qoder-canvas") { return [QODER_CANVAS_FINDINGS_FILE, QODER_CANVAS_DATA_FILE, QODER_CANVAS_FILE]; } + if (mode === "cursor-canvas") { + return [CURSOR_CANVAS_FINDINGS_FILE, CURSOR_CANVAS_DATA_FILE, CURSOR_CANVAS_FILE]; + } if (mode === "markdown") return ["findings.json", "report.md"]; if (mode === "html") return ["findings.json", "report.md", "report.html"]; return []; diff --git a/scripts/harness-analysis/renderers/cursor-canvas.mjs b/scripts/harness-analysis/renderers/cursor-canvas.mjs new file mode 100644 index 0000000..4d78ec1 --- /dev/null +++ b/scripts/harness-analysis/renderers/cursor-canvas.mjs @@ -0,0 +1,32 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const CURSOR_CANVAS_FINDINGS_FILE = "findings.json"; +export const CURSOR_CANVAS_DATA_FILE = "canvas.json"; +export const CURSOR_CANVAS_FILE = "report.canvas.tsx"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CURSOR_CANVAS_TEMPLATE_FILE = path.resolve(__dirname, "../../../templates/canvas/better-harness-cursor.canvas.tsx"); +const DATA_PLACEHOLDER = "/*__BETTER_HARNESS_REPORT__*/ null"; + +export function renderCursorCanvasTsx(reportData) { + const template = readFileSync(CURSOR_CANVAS_TEMPLATE_FILE, "utf8"); + if (!template.includes(DATA_PLACEHOLDER)) { + throw Object.assign(new Error("Cursor Canvas template is missing the report-data placeholder"), { + code: "INVALID_CURSOR_CANVAS_TEMPLATE", + }); + } + const embedded = { + summary: reportData.summary, + findings: reportData.findings, + target: reportData.target, + }; + return template.replace(DATA_PLACEHOLDER, JSON.stringify(embedded)); +} + +export function renderCursorCanvas(reportData) { + return { + [CURSOR_CANVAS_FILE]: renderCursorCanvasTsx(reportData), + }; +} diff --git a/scripts/harness-analysis/report-data-schema.mjs b/scripts/harness-analysis/report-data-schema.mjs index 7b5b55e..097dc69 100644 --- a/scripts/harness-analysis/report-data-schema.mjs +++ b/scripts/harness-analysis/report-data-schema.mjs @@ -14,7 +14,7 @@ import { } from "./task-loop-report.mjs"; import { findingTargetErrors } from "../workspace-topology/index.mjs"; -const ALLOWED_MODES = new Set(["qoder-canvas", "markdown", "html"]); +const ALLOWED_MODES = new Set(["qoder-canvas", "cursor-canvas", "markdown", "html"]); const ALLOWED_LANGUAGES = new Set(["en", "zh-CN"]); const FINDING_OUTPUT_FIELDS = [ "id", diff --git a/scripts/harness-analysis/report-run.mjs b/scripts/harness-analysis/report-run.mjs index 879e7c7..c66eca4 100644 --- a/scripts/harness-analysis/report-run.mjs +++ b/scripts/harness-analysis/report-run.mjs @@ -24,7 +24,7 @@ Options: --since Include sessions at or after the frozen window start --until Include sessions at or before the frozen window end --format Plain-text evidence or evidence plus exact summary facts (default: text) - --canvas-out With Qoder JSON output, initialize canvas.json from exact summary facts + --canvas-out With Qoder or Cursor JSON output, initialize canvas.json from exact summary facts --replace-canvas Replace that exact canvas.json when overwrite was explicitly authorized --include-global-capabilities Include authorized user/global capability metadata @@ -137,9 +137,9 @@ export async function analyzeHarnessEvidence(options = {}) { code: "CANVAS_OUTPUT_REQUIRES_JSON", }); } - if (requestedCanvasOut && platform !== "qoder") { - throw Object.assign(new Error("--canvas-out is supported only for the Better Harness bundle"), { - code: "CANVAS_OUTPUT_REQUIRES_QODER", + if (requestedCanvasOut && !new Set(["qoder", "cursor"]).has(platform)) { + throw Object.assign(new Error("--canvas-out is supported for Qoder and Cursor Canvas bundles"), { + code: "CANVAS_OUTPUT_REQUIRES_CANVAS_HOST", }); } if (requestedCanvasOut && path.basename(String(requestedCanvasOut)) !== "canvas.json") { diff --git a/scripts/harness-analysis/task-loop-report.mjs b/scripts/harness-analysis/task-loop-report.mjs index 48cbc4c..2b7e20e 100644 --- a/scripts/harness-analysis/task-loop-report.mjs +++ b/scripts/harness-analysis/task-loop-report.mjs @@ -74,11 +74,11 @@ const TASK_LOOP_HOST_FINDING_FIELDS = Object.freeze([ const LEGACY_TASK_LOOP_HOST_FINDING_FIELDS = Object.freeze(["kind", "subdimensionRefs", "evidenceBridge"]); const TASK_LOOP_CANVAS_SUMMARY_FIELDS = Object.freeze([ "evidenceMode", "atAGlance", "evidenceBoundary", "semanticFacets", - "usageActivity", "usageEfficiency", "learningCapture", + "usageActivity", "usageEfficiency", "contextUsage", "learningCapture", ]); const TASK_LOOP_MACHINE_SUMMARY_FACT_FIELDS = Object.freeze([ "evidenceMode", "evidenceBoundary", "semanticFacets", "learningCapture", - "usageActivity", "usageEfficiency", + "usageActivity", "usageEfficiency", "contextUsage", ]); const TASK_LOOP_CANVAS_DIMENSION_FIELDS = Object.freeze([ "id", "level", "state", "subdimensions", "evidenceBridge", "blocker", "scoreReason", "scoreConfidence", "scoreEvidenceRefs", @@ -1626,6 +1626,9 @@ export function projectTaskLoopReportFacts(source) { ? { usageActivity: JSON.parse(JSON.stringify(source.sessionEvents.usageActivity)) } : {}), ...(usageEfficiency ? { usageEfficiency } : {}), + ...(source?.sessionEvents?.contextUsage + ? { contextUsage: JSON.parse(JSON.stringify(source.sessionEvents.contextUsage)) } + : {}), }; const usageErrors = validateTaskLoopUsagePair(summaryFacts); if (usageErrors.length > 0) { @@ -1649,6 +1652,9 @@ function taskLoopSummaryFactsErrors(summaryFacts) { if (summaryFacts.aiAgentPractice !== undefined) { errors.push(...aiAgentPracticeErrors(summaryFacts.aiAgentPractice)); } + if (summaryFacts.contextUsage !== undefined) { + errors.push(...contextUsageErrors(summaryFacts.contextUsage)); + } errors.push(...requiredTaskLoopUsageErrors(summaryFacts)); errors.push(...validateTaskLoopUsagePair(summaryFacts)); } @@ -2423,6 +2429,9 @@ export function projectTaskLoopFindings(source, { projectName = "", direct = fal ...(usageEfficiency ? { usageEfficiency } : {}), + ...(source?.sessionEvents?.contextUsage + ? { contextUsage: JSON.parse(JSON.stringify(source.sessionEvents.contextUsage)) } + : {}), }, findings, }; @@ -2742,6 +2751,7 @@ export function validateCompactTaskLoopFindings(data) { errors.push("findings.json summary.strengths must be an array of non-empty strings when supplied"); } if (summary.aiAgentPractice !== undefined) errors.push(...aiAgentPracticeErrors(summary.aiAgentPractice)); + if (summary.contextUsage !== undefined) errors.push(...contextUsageErrors(summary.contextUsage)); if (summary.assignmentSummaries !== undefined && !Array.isArray(summary.assignmentSummaries)) { errors.push("findings.json summary.assignmentSummaries must be an array when supplied"); } @@ -2906,6 +2916,7 @@ export function validateTaskLoopCanvasSplit(findings, canvas) { errors.push("canvas.json summary must be an object"); } else { errors.push(...unsupportedFields(canvas.summary, TASK_LOOP_CANVAS_SUMMARY_FIELDS, "canvas.json summary")); + if (canvas.summary.contextUsage !== undefined) errors.push(...contextUsageErrors(canvas.summary.contextUsage)); if (Object.hasOwn(canvas.summary, "strengths")) { errors.push("canvas.json summary must not duplicate host-owned strengths"); } @@ -3022,6 +3033,85 @@ function safePracticePathForSurface(surface, value) { return /(?:^|\/)MEMORY\.md$/u.test(candidate); } +function contextUsageErrors(usage) { + if (!isObject(usage)) return ["summary.contextUsage must be an object when supplied"]; + const errors = []; + const prefix = "summary.contextUsage"; + errors.push(...unsupportedFields(usage, [ + "schemaVersion", "status", "evidence", "capturedAt", "totalTokensUsed", + "contextWindowSize", "percentFull", "categories", "items", "coverage", "actions", + ], prefix)); + if (!Number.isInteger(usage.schemaVersion) || usage.schemaVersion < 1) { + errors.push(`${prefix}.schemaVersion must be a positive integer`); + } + if (!new Set(["observed", "unobserved"]).has(usage.status)) { + errors.push(`${prefix}.status must be observed or unobserved`); + } + if (usage.evidence !== "cursor-native-context-usage-canvas") { + errors.push(`${prefix}.evidence must identify the Cursor native Canvas snapshot`); + } + if (!Array.isArray(usage.categories)) errors.push(`${prefix}.categories must be an array`); + else for (const [index, category] of usage.categories.entries()) { + const categoryPrefix = `${prefix}.categories[${index}]`; + if (!isObject(category)) { + errors.push(`${categoryPrefix} must be an object`); + continue; + } + errors.push(...unsupportedFields(category, ["id", "label", "estimatedTokens"], categoryPrefix)); + if (typeof category.id !== "string" || !category.id.trim() || category.id.length > 80) errors.push(`${categoryPrefix}.id must be bounded text`); + if (typeof category.label !== "string" || !category.label.trim() || category.label.length > 120) errors.push(`${categoryPrefix}.label must be bounded text`); + if (!Number.isInteger(category.estimatedTokens) || category.estimatedTokens < 0) errors.push(`${categoryPrefix}.estimatedTokens must be non-negative`); + } + if (!Array.isArray(usage.items)) errors.push(`${prefix}.items must be an array`); + else { + if (usage.items.length > 200) errors.push(`${prefix}.items must contain at most 200 rows`); + for (const [index, item] of usage.items.entries()) { + const itemPrefix = `${prefix}.items[${index}]`; + if (!isObject(item)) { + errors.push(`${itemPrefix} must be an object`); + continue; + } + errors.push(...unsupportedFields(item, ["id", "parentId", "categoryId", "label", "estimatedTokens", "characterCount", "source"], itemPrefix)); + for (const field of ["id", "categoryId", "label"]) { + if (typeof item[field] !== "string" || !item[field].trim()) errors.push(`${itemPrefix}.${field} must be non-empty text`); + } + for (const field of ["estimatedTokens", "characterCount"]) { + if (!Number.isInteger(item[field]) || item[field] < 0) errors.push(`${itemPrefix}.${field} must be non-negative`); + } + if (item.source !== undefined) { + if (!isObject(item.source)) errors.push(`${itemPrefix}.source must be an object`); + else { + errors.push(...unsupportedFields(item.source, ["kind", "path", "label"], `${itemPrefix}.source`)); + if (item.source.kind !== "file" || !path.isAbsolute(String(item.source.path ?? ""))) { + errors.push(`${itemPrefix}.source must identify an absolute local file`); + } + } + } + } + } + if (!isObject(usage.coverage)) errors.push(`${prefix}.coverage must be an object`); + else { + errors.push(...unsupportedFields(usage.coverage, ["snapshotCount", "itemCount", "sourceItemCount", "truncated", "rawTextOmitted"], `${prefix}.coverage`)); + if (usage.coverage.rawTextOmitted !== true) errors.push(`${prefix}.coverage.rawTextOmitted must be true`); + } + if (!isObject(usage.actions)) errors.push(`${prefix}.actions must be an object`); + else { + errors.push(...unsupportedFields(usage.actions, ["openAgentId"], `${prefix}.actions`)); + if (usage.actions.openAgentId !== null + && (typeof usage.actions.openAgentId !== "string" || !usage.actions.openAgentId.trim() || usage.actions.openAgentId.length > 120)) { + errors.push(`${prefix}.actions.openAgentId must be null or bounded transport text`); + } + } + if (usage.status === "observed") { + if (!Number.isInteger(usage.totalTokensUsed) || usage.totalTokensUsed <= 0) errors.push(`${prefix}.totalTokensUsed must be positive when observed`); + if (!Number.isInteger(usage.contextWindowSize) || usage.contextWindowSize <= 0) errors.push(`${prefix}.contextWindowSize must be positive when observed`); + if (!Number.isInteger(usage.percentFull) || usage.percentFull < 0 || usage.percentFull > 100) errors.push(`${prefix}.percentFull must be 0-100 when observed`); + } else if (usage.categories.length > 0 || usage.items.length > 0) { + errors.push(`${prefix} unobserved snapshots must not contain category or item claims`); + } + return errors; +} + function aiAgentPracticeErrors(practice) { if (!isObject(practice)) return ["findings.json summary.aiAgentPractice must be an object"]; const errors = []; @@ -3346,7 +3436,7 @@ export function validateTaskLoopFindings(data) { const summary = data.summary; if (!isObject(summary)) return [...errors, "findings.json summary must be an object"]; const summaryFields = ["projectName", "locale", "modelId", "reportContractVersion", "strengths", "atAGlance", "evidenceBoundary", "dimensions", "aiAgentPractice", "semanticFacets", "learningCapture"]; - errors.push(...unsupportedFields(summary, [...summaryFields, "overview", "evidenceMode", "usageActivity", "usageEfficiency", "suggestions", "assignmentSummaries"], "findings.json summary")); + errors.push(...unsupportedFields(summary, [...summaryFields, "overview", "evidenceMode", "usageActivity", "usageEfficiency", "contextUsage", "suggestions", "assignmentSummaries"], "findings.json summary")); for (const field of summaryFields) { if (summary[field] === undefined || summary[field] === null || summary[field] === "") errors.push(`findings.json summary missing ${field}`); } @@ -3584,6 +3674,9 @@ export function validateTaskLoopFindings(data) { } } } + if (summary.contextUsage !== undefined) { + errors.push(...contextUsageErrors(summary.contextUsage)); + } if (!Array.isArray(summary.dimensions) || summary.dimensions.length !== validationDimensionIds.length) { errors.push(`findings.json summary.dimensions must contain exactly ${validationDimensionIds.length} task-loop dimensions`); } diff --git a/scripts/harness-analysis/task-loop-source.mjs b/scripts/harness-analysis/task-loop-source.mjs index 3d5e963..4f20f3a 100644 --- a/scripts/harness-analysis/task-loop-source.mjs +++ b/scripts/harness-analysis/task-loop-source.mjs @@ -717,6 +717,7 @@ export function buildTaskLoopSourceCandidate({ priorLearningCaptureEvidenceRef = null, includeUsage = false, memoryInventory, + contextUsage = null, } = {}) { const readerLocale = normalizeReaderLocale(locale); const episodeAnalysis = buildTaskEpisodes( @@ -789,6 +790,7 @@ export function buildTaskLoopSourceCandidate({ ? { usageActivity: insights.keySignals.usageEfficiency.activity } : {}), ...(usageSummary ? { usageEfficiency: usageSummary } : {}), + ...(contextUsage ? { contextUsage: JSON.parse(JSON.stringify(contextUsage)) } : {}), }, taskEpisodes, deliveryEvidence: focusedCheckEvidence(taskEpisodes), @@ -1243,6 +1245,7 @@ export async function createTaskLoopSourceFromSessions(options = {}) { priorLearningCaptureEvidenceRef: priorLearningCaptureState.evidenceRef, includeUsage, memoryInventory: practiceInventory?.memories ?? { included: false, categories: [] }, + contextUsage: insightResult.contextUsage ?? null, }); assertStandardUsageComplete(source, selected, includeUsage); if (!population) return { source, selection: selected }; diff --git a/scripts/harness-analysis/validate-cursor-canvas.mjs b/scripts/harness-analysis/validate-cursor-canvas.mjs new file mode 100644 index 0000000..8c5f37e --- /dev/null +++ b/scripts/harness-analysis/validate-cursor-canvas.mjs @@ -0,0 +1,98 @@ +import { readFileSync } from "node:fs"; + +import { analyzeCanvasModuleBoundaries } from "./canvas-module-boundaries.mjs"; +import { transformCanvasSource } from "./canvas-preview/transform.mjs"; +import { validateTaskLoopCanvasSplit } from "./task-loop-report.mjs"; + +function check(id, errors = [], warnings = [], summary = {}) { + return { id, status: errors.length > 0 ? "fail" : warnings.length > 0 ? "warn" : "pass", errors, warnings, summary }; +} + +function parseJson(filePath, label, errors) { + try { + return JSON.parse(readFileSync(filePath, "utf8")); + } catch (error) { + errors.push(`${label} is not readable JSON: ${error.message}`); + return null; + } +} + +export async function validateCursorCanvasArtifacts({ canvasPath, findingsPath, canvasDataPath }) { + const checks = []; + let source = ""; + const readErrors = []; + try { source = readFileSync(canvasPath, "utf8"); } catch (error) { readErrors.push(`report.canvas.tsx is not readable: ${error.message}`); } + const findings = parseJson(findingsPath, "findings.json", readErrors); + const canvas = parseJson(canvasDataPath, "canvas.json", readErrors); + checks.push(check("cursor-canvas-inputs", readErrors, [], { + canvasPath, + findingsPath, + canvasDataPath, + })); + + if (readErrors.length === 0) { + const splitErrors = validateTaskLoopCanvasSplit(findings, canvas); + checks.push(check("cursor-canvas-data", splitErrors, [], { + findingCount: Array.isArray(findings?.findings) ? findings.findings.length : 0, + contextUsageStatus: canvas?.summary?.contextUsage?.status ?? "unobserved", + })); + + const boundaries = analyzeCanvasModuleBoundaries(source); + const allowed = new Set(["cursor/canvas"]); + const boundaryErrors = [ + ...(boundaries.syntaxError ? ["report.canvas.tsx has invalid module syntax"] : []), + ...(boundaries.dynamicImport ? ["report.canvas.tsx must not use dynamic imports"] : []), + ...boundaries.staticSources.filter((specifier) => !allowed.has(specifier)) + .map((specifier) => `report.canvas.tsx imports unsupported module: ${specifier}`), + ...(!boundaries.staticSources.includes("cursor/canvas") ? ["report.canvas.tsx must import cursor/canvas"] : []), + ...(source.includes("__BETTER_HARNESS_REPORT__") ? ["report.canvas.tsx still contains the data placeholder"] : []), + ...(source.includes("qoder/canvas") ? ["Cursor Canvas must not import qoder/canvas"] : []), + ]; + checks.push(check("cursor-canvas-boundaries", boundaryErrors, [], { + imports: boundaries.staticSources, + })); + + const requiredPatterns = [ + ["complete report header", /BETTER HARNESS · AGENT WORK LOOP/u], + ["Fluency section", /function FluencyDimensions/u], + ["project usage section", /function ProjectUsage/u], + ["AI Agent Practice section", /function AgentPractice/u], + ["Context Window section", /function ContextWindow/u], + ["findings section", /function Findings/u], + ["evidence section", /function EvidenceAndMethodology/u], + ["newComposerChat action", /type:\s*["']newComposerChat["']/u], + ["openFile action", /type:\s*["']openFile["']/u], + ["openAgent action", /type:\s*["']openAgent["']/u], + ["UsageBar", / !pattern.test(source)) + .map(([label]) => `report.canvas.tsx is missing ${label}`); + checks.push(check("cursor-canvas-content", contentErrors, [], { + sectionCount: requiredPatterns.filter(([, pattern]) => pattern.test(source)).length, + requiredSectionCount: requiredPatterns.length, + })); + + const transformErrors = []; + let bytes = 0; + try { + const transformed = transformCanvasSource(source, { + sourcefile: "report.canvas.tsx", + sourcePath: canvasPath, + }); + bytes = Buffer.byteLength(transformed.code); + } catch (error) { + transformErrors.push(`Cursor Canvas TSX transform failed: ${error.message}`); + } + checks.push(check("cursor-canvas-transform", transformErrors, [], { transformedBytes: bytes })); + } + + const errors = checks.flatMap((entry) => entry.errors.map((error) => `${entry.id}: ${error}`)); + const warnings = checks.flatMap((entry) => entry.warnings.map((warning) => `${entry.id}: ${warning}`)); + return { + status: errors.length > 0 ? "fail" : warnings.length > 0 ? "warn" : "pass", + checks, + errors, + warnings, + }; +} diff --git a/scripts/session-analysis/platforms/cursor.mjs b/scripts/session-analysis/platforms/cursor.mjs index b854df8..5ad1d1c 100644 --- a/scripts/session-analysis/platforms/cursor.mjs +++ b/scripts/session-analysis/platforms/cursor.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import path from "node:path"; +import { stat } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { SessionAnalyzer } from "../analyzer.mjs"; @@ -19,12 +20,160 @@ import { import { parseResultFacts } from "../result-facts.mjs"; import { mergeTimeRange, normalizeCliDate, normalizeTimestamp, timestampMillis, withinTimeRange } from "../time.mjs"; +export const CURSOR_CONTEXT_USAGE_SCHEMA_VERSION = 1; +const CURSOR_CONTEXT_USAGE_ITEM_LIMIT = 200; + function isWorkspaceMatch(candidate, workspace) { if (!candidate) return false; const resolved = normalizeWorkspace(candidate); return resolved === workspace || resolved.startsWith(`${workspace}${path.sep}`); } +function boundedText(value, limit = 160) { + return String(value ?? "").trim().slice(0, limit); +} + +function nonNegativeInteger(value) { + const number = Number(value); + return Number.isFinite(number) && number >= 0 ? Math.round(number) : 0; +} + +function pathIsWithin(root, candidate) { + if (!root || !candidate || !path.isAbsolute(candidate)) return false; + const relative = path.relative(path.resolve(root), path.resolve(candidate)); + return relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`)); +} + +function safeContextSource(source, scope) { + if (source?.kind !== "file" || !path.isAbsolute(String(source.path ?? ""))) return null; + const roots = [ + scope.workspace, + scope._workspaceMatchScope?.requestedWorkspace, + scope._workspaceMatchScope?.gitRoot, + ].filter(Boolean); + if (!roots.some((root) => pathIsWithin(root, source.path))) return null; + return { + kind: "file", + path: path.resolve(source.path), + label: boundedText(source.label || path.basename(source.path), 120), + }; +} + +function contextItemLabel(item, index, admittedSource) { + const sourceLabel = boundedText(admittedSource?.label ?? item?.source?.label, 160); + if (sourceLabel && !path.isAbsolute(sourceLabel)) return sourceLabel; + const candidate = boundedText(item?.label, 240); + if (!candidate) return `Context item ${index + 1}`; + if (!path.isAbsolute(candidate)) return candidate.slice(0, 160); + const parent = path.basename(path.dirname(candidate)); + const base = path.basename(candidate); + return boundedText(parent && parent !== "." ? `${parent}/${base}` : base, 160); +} + +function projectContextUsageSnapshot(raw, { capturedAt, scope } = {}) { + const usage = raw?.contextUsage; + if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null; + const contextWindowSize = nonNegativeInteger(usage.contextWindowSize); + const totalTokensUsed = nonNegativeInteger(usage.totalTokensUsed); + if (contextWindowSize <= 0 || totalTokensUsed <= 0) return null; + + const rawItems = Array.isArray(usage.items) ? usage.items.slice(0, CURSOR_CONTEXT_USAGE_ITEM_LIMIT) : []; + const itemIds = new Map(rawItems.map((item, index) => [String(item?.id ?? `item-${index + 1}`), `item-${index + 1}`])); + const items = rawItems.map((item, index) => { + const source = safeContextSource(item?.source, scope); + const parentId = item?.parentId === undefined || item?.parentId === null + ? null + : itemIds.get(String(item.parentId)) ?? null; + return { + id: `item-${index + 1}`, + parentId, + categoryId: boundedText(item?.categoryId, 80) || "other", + label: contextItemLabel(item, index, source), + estimatedTokens: nonNegativeInteger(item?.estimatedTokens), + characterCount: nonNegativeInteger(item?.characterCount), + ...(source ? { source } : {}), + }; + }); + + return { + schemaVersion: CURSOR_CONTEXT_USAGE_SCHEMA_VERSION, + status: "observed", + evidence: "cursor-native-context-usage-canvas", + capturedAt, + totalTokensUsed, + contextWindowSize, + percentFull: Math.min(100, Math.round((totalTokensUsed / contextWindowSize) * 100)), + categories: (Array.isArray(usage.categories) ? usage.categories : []) + .map((category) => ({ + id: boundedText(category?.id, 80) || "other", + label: boundedText(category?.label, 120) || "Other", + estimatedTokens: nonNegativeInteger(category?.tokens), + })) + .filter((category) => category.estimatedTokens > 0), + items, + coverage: { + itemCount: items.length, + sourceItemCount: Array.isArray(usage.items) ? usage.items.length : 0, + truncated: Array.isArray(usage.items) && usage.items.length > items.length, + rawTextOmitted: true, + }, + actions: { + openAgentId: boundedText(usage.composerId, 120) || null, + }, + }; +} + +export async function readCursorContextUsage(scope) { + const canvasRoots = scope._workspaceSlugVariants + .map((slug) => path.join(scope.home, "projects", slug, "canvases")); + const candidates = []; + for (const root of canvasRoots) { + if (!await pathExists(root)) continue; + const files = await walkFiles(root, { + maxDepth: 1, + limit: 2_000, + match: (file) => /^context-usage-.+\.canvas\.data\.json$/u.test(path.basename(file)), + }); + for (const filePath of files) { + let metadata; + try { metadata = await stat(filePath); } catch { continue; } + const capturedAt = new Date(metadata.mtimeMs).toISOString(); + if (!withinTimeRange(capturedAt, scope)) continue; + candidates.push({ filePath, capturedAt, mtimeMs: metadata.mtimeMs }); + } + } + candidates.sort((left, right) => right.mtimeMs - left.mtimeMs || left.filePath.localeCompare(right.filePath)); + for (const candidate of candidates) { + let raw; + try { raw = await readJson(candidate.filePath); } catch { continue; } + const projected = projectContextUsageSnapshot(raw, { capturedAt: candidate.capturedAt, scope }); + if (projected) { + return { + ...projected, + coverage: { + ...projected.coverage, + snapshotCount: candidates.length, + }, + }; + } + } + return { + schemaVersion: CURSOR_CONTEXT_USAGE_SCHEMA_VERSION, + status: "unobserved", + evidence: "cursor-native-context-usage-canvas", + categories: [], + items: [], + coverage: { + snapshotCount: candidates.length, + itemCount: 0, + sourceItemCount: 0, + truncated: false, + rawTextOmitted: true, + }, + actions: { openAgentId: null }, + }; +} + export function workspaceToCursorSlugVariants(workspace) { const expanded = expandHome(workspace ?? process.cwd()); const normalized = path.win32.isAbsolute(expanded) ? path.win32.normalize(expanded) : normalizeWorkspace(expanded); @@ -470,6 +619,17 @@ export class CursorSessionAnalyzer extends SessionAnalyzer { workspaceScoped: false, coverage: "session-time", }, + { + id: "cursor-context-usage", + kind: "cursor-context-usage-canvas", + role: "context-usage-snapshot", + path: path.join(scope.home, "projects", scope._workspaceSlugVariants[0], "canvases"), + paths: scope._workspaceSlugVariants.map((slug) => path.join(scope.home, "projects", slug, "canvases")), + optional: true, + enabled: true, + workspaceScoped: true, + coverage: "optional-context-usage", + }, { id: "cursor-audit", kind: "cursor-audit-jsonl", @@ -700,7 +860,13 @@ export class CursorSessionAnalyzer extends SessionAnalyzer { } async analyze(options = {}) { - return runProviderAnalysis(this, options, { platform: "cursor", adapterVersion: "cursor-v1" }); + const result = await runProviderAnalysis(this, options, { platform: "cursor", adapterVersion: "cursor-v1" }); + if (!["insights", "facts"].includes(options.command)) return result; + const scope = await this.resolveScope(options); + return { + ...result, + contextUsage: await readCursorContextUsage(scope), + }; } } diff --git a/skills/better-harness/SKILL.md b/skills/better-harness/SKILL.md index 34887d1..a33b347 100644 --- a/skills/better-harness/SKILL.md +++ b/skills/better-harness/SKILL.md @@ -22,8 +22,8 @@ any owner is missing; never select another cache or runtime by search order. Resolve the absolute target, decision, acceptance boundary, risks, locale (the user's request language unless explicitly changed), output mode, provider scope, and evidence depth. Quick uses three assets or Episodes and the previous 7 days; normal uses five and the previous 30 days. -Default Qoder to durable Canvas and other providers to durable HTML. Only an -explicit inline or no-files request writes nothing. +Default Qoder/Cursor to durable Canvas; default others to durable HTML. +Only an explicit inline or no-files request writes nothing. Keep providers separate. Use the current one unless project-wide review explicitly authorizes multiple supported providers. Qoder project Memory title metadata is part of @@ -38,7 +38,7 @@ provider: harness evidence-bundle --platform --workspace --language --depth --since --until --format json [--include-memories] [--include-user-home] [--canvas-out /canvas.json] ``` -Use `--canvas-out` only for Qoder durable-report runs. For Qoder, keep the +Use `--canvas-out` only for Qoder/Cursor durable reports. For Qoder, keep the default project Memory-title scan; `--include-user-home` widens it to authorized global Memory/config and other user assets. For Codex, Memory metadata requires `--include-memories`; user/global or installed-Plugin @@ -181,14 +181,13 @@ Inline analysis writes nothing. After lead checks pass, treat the draft as the o final `findings.json`, then render and validate it once: ```text -Qoder: =qoder-canvas; =/.qoder/better-harness +Qoder/Cursor: =-canvas; =/./better-harness Other providers: =html; =/./better-harness -HTML artifacts: findings.json, report.md, report.html harness render --findings /findings.json --mode --out --run-dir --target --validate --json ``` -Qoder analysis owns adjacent `canvas.json`; do not copy its `summaryFacts` into -findings. HTML keeps analyzer `summaryFacts` verbatim. Succeed only on +Qoder/Cursor analysis owns adjacent `canvas.json`; do not copy its `summaryFacts` +into findings. HTML keeps analyzer `summaryFacts` verbatim. Succeed only on `status: pass` and return the exact paths reported by render. Never hand-write Canvas, Markdown, or HTML. diff --git a/templates/canvas/better-harness-cursor.canvas.tsx b/templates/canvas/better-harness-cursor.canvas.tsx new file mode 100644 index 0000000..3330239 --- /dev/null +++ b/templates/canvas/better-harness-cursor.canvas.tsx @@ -0,0 +1,463 @@ +import { + BarChart, + Button, + Callout, + Card, + CardBody, + CardHeader, + CollapsibleSection, + Divider, + Grid, + H1, + H2, + H3, + Pill, + Row, + Stack, + Stat, + Swatch, + Table, + Text, + UsageBar, + usageColorSequence, + useCanvasAction, + useHostTheme, +} from "cursor/canvas"; + +const report = /*__BETTER_HARNESS_REPORT__*/ null as any; + +function list(value: any): any[] { + return Array.isArray(value) ? value.filter(Boolean) : []; +} + +function copy(english: string, chinese: string): string { + return report?.summary?.locale === "zh-CN" ? chinese : english; +} + +function number(value: any): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function formatNumber(value: any): string { + return new Intl.NumberFormat(report?.summary?.locale === "zh-CN" ? "zh-CN" : "en-US", { + maximumFractionDigits: 1, + }).format(number(value)); +} + +function formatTokens(value: any): string { + const tokens = number(value); + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; + if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`; + return formatNumber(tokens); +} + +function findingFilePath(finding: any): string | null { + const target = finding?.target; + for (const candidate of [target?.path, target?.file, target?.workspaceRelativePath]) { + if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); + } + return null; +} + +function OpenFileButton({ path, label }: { path?: string | null; label?: string }) { + const dispatch = useCanvasAction(); + if (!path) return null; + return ( + + ); +} + +function NewChatButton({ prompt, label, primary = false }: { prompt?: string | null; label: string; primary?: boolean }) { + const dispatch = useCanvasAction(); + return ( + + ); +} + +function OpenAgentButton({ agentId }: { agentId?: string | null }) { + const dispatch = useCanvasAction(); + if (!agentId) return null; + return ( + + ); +} + +function ReportHeader() { + const contextUsage = report?.summary?.contextUsage; + return ( + + BETTER HARNESS · AGENT WORK LOOP +

{report?.summary?.projectName ?? copy("Better Harness report", "Better Harness 报告")}

+ + {report?.summary?.overview ?? copy("Evidence-bounded engineering readiness report.", "基于证据边界的工程就绪度报告。")} + + + {report?.summary?.evidenceMode ?? "unobserved"} + {copy("No aggregate score", "不计算总分")} + + + +
+ ); +} + +function Strengths() { + const strengths = list(report?.summary?.strengths); + if (strengths.length === 0) return null; + return ( + +

{copy("What is already working", "已有的有效基础")}

+ + {strengths.slice(0, 4).map((strength: string, index: number) => ( + {strength} + ))} + +
+ ); +} + +function FluencyDimensions() { + const dimensions = list(report?.summary?.dimensions); + return ( + + +

{copy("Fluency", "Fluency")}

+ + {copy( + "Five evidence-bounded dimensions. Scores stay separate so one strong area cannot hide another area's gap.", + "五个有证据边界的维度分别呈现,避免某个强项掩盖其他环节的缺口。", + )} + +
+ dimension.label ?? dimension.id)} + series={[{ name: copy("Score", "分数"), data: dimensions.map((dimension: any) => number(dimension.score)) }]} + horizontal + height={Math.max(240, dimensions.length * 54)} + yMin={0} + yMax={100} + valueSuffix=" / 100" + showValues + /> + + {dimensions.map((dimension: any) => ( + + = 70}>{formatNumber(dimension.score)}}> + {dimension.label ?? dimension.id} + + + {dimension.summary ?? dimension.scoreReason ?? "—"} + + + ))} + +
+ ); +} + +function ProjectUsage() { + const activity = report?.summary?.usageActivity; + const efficiency = report?.summary?.usageEfficiency; + if (!activity && !efficiency) { + return ( + +

{copy("Project usage", "项目使用情况")}

+ {copy("Session usage was not observed for this report window.", "本次报告窗口没有观察到会话使用数据。")} +
+ ); + } + const dates = list(activity?.dates).slice(-30); + const activeMinutes = list(activity?.sessions?.activeMinutes).slice(-dates.length).map(number); + const selection = efficiency?.selection ?? {}; + return ( + +

{copy("Project usage", "项目使用情况")}

+ + + sum + value, 0))} label={copy("Active minutes · 30d", "活跃分钟 · 30 天")} /> + 0 ? "warning" : undefined} /> + + + {dates.length > 0 ? ( + + ) : null} +
+ ); +} + +function AgentPractice() { + const practice = report?.summary?.aiAgentPractice ?? {}; + const rows = list(practice.coverageRows); + return ( + +

{copy("AI Agent Practice", "AI Agent Practice")}

+ + {copy("Configured assets and representative safe sources observed by this scan.", "本次扫描观察到的 Agent 配置资产及代表性安全来源。")} + + { + const paths = list(entry.paths).filter((value: any) => typeof value === "string"); + return [ + entry.surface ?? "—", + `${formatNumber(entry.count)} · ${list(entry.scopes).join(", ") || "—"}`, + paths[0] ? : "—", + ]; + })} + emptyMessage={copy("No configured asset inventory was retained.", "未保留配置资产清单。")} + striped + /> + + ); +} + +function ContextWindow() { + const usage = report?.summary?.contextUsage; + if (!usage || usage.status !== "observed") { + return ( + +

{copy("Context Window", "Context Window")}

+ + {copy( + "Cursor did not expose a workspace Context Usage canvas for this report window. No token total or category share is inferred.", + "Cursor 在本次报告窗口中没有提供工作区 Context Usage Canvas,因此不会推断 token 总量或分类占比。", + )} + +
+ ); + } + const categories = list(usage.categories); + const items = list(usage.items); + return ( + + + +

{copy("Context Window", "Context Window")}

+ + {copy("Latest workspace-scoped native Cursor snapshot; raw item text is omitted.", "最新的工作区级 Cursor 原生快照;原始条目文本已省略。")} + +
+ +
+ ({ + id: category.id, + value: number(category.estimatedTokens), + color: usageColorSequence[index % usageColorSequence.length], + }))} + /> + + {categories.map((category: any, index: number) => { + const categoryItems = items + .filter((item: any) => item.categoryId === category.id) + .sort((left: any, right: any) => number(right.estimatedTokens) - number(left.estimatedTokens)); + return ( + } + trailing={{formatTokens(category.estimatedTokens)} tokens} + > + + {categoryItems.slice(0, 20).map((item: any) => ( + + + {item.label} + + {formatTokens(item.estimatedTokens)} tokens · {formatNumber(item.characterCount)} chars + + + + + ))} + {categoryItems.length > 20 ? ( + {copy("Additional bounded items omitted from the default view.", "其余有界条目已从默认视图中省略。")} + ) : null} + + + ); + })} + +
+ ); +} + +function Findings() { + const findings = list(report?.findings); + return ( + + + +

{copy("Prioritized improvements", "优先改进项")}

+ {copy(`${findings.length} evidence-backed findings`, `${findings.length} 项有证据支持的发现`)} +
+
+ + {findings.map((finding: any) => { + const outputs = list(finding.expectedOutput); + const filePath = findingFilePath(finding); + return ( + + {finding.severity ?? "—"}}> + {finding.title ?? finding.id} + + + + {finding.reason ?? "—"} + {outputs.length > 0 ? ( + + + {outputs.map((output: string, index: number) => ( + {index + 1}. {output} + ))} + + + ) : null} + + + + + + + + + ); + })} + +
+ ); +} + +function Suggestions() { + const suggestions = list(report?.summary?.suggestions); + if (suggestions.length === 0) return null; + return ( + +

{copy("Next opportunities", "后续机会")}

+ {suggestions.map((suggestion: any) => ( + {suggestion.confidence ?? "—"}} + > + + {suggestion.reason ?? "—"} + {copy("Next step: ", "下一步:")}{suggestion.nextStep ?? "—"} + + + + ))} +
+ ); +} + +function EvidenceAndMethodology() { + const boundary = report?.summary?.evidenceBoundary ?? {}; + const manifest = boundary.manifest ?? {}; + const selection = manifest.selection ?? {}; + const facets = list(report?.summary?.semanticFacets?.entries); + const sourceGaps = list(boundary.sourceGaps); + return ( + +

{copy("Evidence and methodology", "证据与方法")}

+ 0 ? "warning" : "info"} title={copy("Decision boundary", "决策边界")}> + {copy( + `Analyzed ${formatNumber(selection.analyzedCount)} of ${formatNumber(selection.eligibleCount)} eligible sessions with ${selection.confidence ?? "unknown"} sampling confidence.`, + `共分析 ${formatNumber(selection.analyzedCount)} / ${formatNumber(selection.eligibleCount)} 个符合条件的会话,抽样可信度为 ${selection.confidence ?? "未知"}。`, + )} + + + + + 0 ? "warning" : "success"} /> + + {facets.length > 0 ? ( + +

{copy("Session observations", "会话观察")}

+ + {facets.slice(0, 6).map((facet: any, index: number) => ( + + {facet.title ?? facet.label ?? copy("Observation", "观察")} + {facet.summary ?? facet.body ?? "—"} + + ))} + +
+ ) : null} + + + {sourceGaps.length > 0 + ? sourceGaps.map((gap: string) => {gap}) + : {copy("No declared source gaps.", "没有声明的来源缺口。")}} + + +
+ ); +} + +export default function BetterHarnessCursorReport() { + const theme = useHostTheme(); + return ( + + + + + + + + + + + + + ); +} diff --git a/templates/reporting/cursor-canvas.md b/templates/reporting/cursor-canvas.md new file mode 100644 index 0000000..771a1f0 --- /dev/null +++ b/templates/reporting/cursor-canvas.md @@ -0,0 +1,69 @@ +# Cursor Canvas Output + +This file owns the Cursor Canvas artifact, runtime-action, and validation +contract. Reader fields remain owned by `skills/better-harness/SKILL.md`; this +mode renders the same complete reviewed report as Qoder Canvas with Cursor's +public SDK. + +## Artifact flow + +- `harness analyze --platform cursor --format json --canvas-out + /canvas.json` writes exact analyzer-owned summary facts only when the + output is explicit. JSON without `--canvas-out` remains write-free. +- Cursor analysis discovers optional workspace-scoped native + `context-usage-*.canvas.data.json` snapshots. It retains bounded token, + category, hierarchy, and safe file metadata; raw item text is always omitted. + Missing snapshots are `unobserved`, never zero usage. +- The lead authors the same complete reviewed `findings.json` contract used by + the other report routes. Render automatically consumes an adjacent + analyzer-owned `canvas.json` when present. +- `harness render --mode cursor-canvas` writes `findings.json`, `canvas.json`, + and `report.canvas.tsx`. The TSX embeds the merged complete report so Cursor + does not depend on private companion-data loading behavior; the sibling JSON + files remain the durable evidence and review contract. + +## Complete reader mode + +Render the complete Agent Work Loop report, not a Context Usage-only view: + +1. project introduction, evidence mode, strengths, and five separate Fluency + dimensions without an aggregate score; +2. project usage and session-efficiency boundaries; +3. AI Agent Practice inventory with safe file routes; +4. Context Window usage, category shares, and bounded item metadata, or an + explicit unavailable state; +5. every eligible prioritized finding and expected output; +6. suggestions plus evidence and methodology. + +Follow the compact reader semantics in [Qoder Canvas Output](qoder-canvas.md) +where the two public SDKs have equivalent primitives. Cursor's `BarChart` +projects the five independent dimension scores because Cursor does not expose +Qoder's `Fluency` component. + +## Native actions + +Use only `useCanvasAction()` from `cursor/canvas`: + +- `newComposerChat` receives a finding's unchanged `aiFixPrompt` or a bounded + report/context review prompt. Cursor automatically references the dispatching + Canvas. +- `openFile` receives a safe report or Context Usage file path. Omit the action + when no admitted path exists. +- `openAgent` receives an observed Cursor composer id as hidden action + transport. Do not print the raw id as reader copy. + +Never invoke private Cursor command ids, import Cursor workbench internals, or +copy the proprietary generated Context Usage template. + +## Validation + +Run: + +```text +better-harness harness render --findings /findings.json --mode cursor-canvas --out /.cursor/better-harness --run-dir --target --validate --json +``` + +Validation checks the split report contract, final artifact inventory, module +boundaries, required complete-report sections, public action bindings, and the +final TSX transform. A successful transform is the minimum delivery gate; +visual changes should also be opened in Cursor Canvas for layout inspection. diff --git a/templates/reporting/routing.md b/templates/reporting/routing.md index c011b50..88a7a1c 100644 --- a/templates/reporting/routing.md +++ b/templates/reporting/routing.md @@ -28,6 +28,7 @@ files from other routes. | Route | Use when | Artifacts | Runtime owner | | --- | --- | --- | --- | | Qoder Canvas report | Active host is Qoder | renderer-owned `findings.json`, `canvas.json`, `report.canvas.tsx` | `qoder-canvas.md` | -| Portable HTML report | Active host is Claude Code, Codex, Cursor, Qwen Code, GitHub Copilot, Pi, or WorkBuddy, or a portable visual is explicitly requested | renderer-owned `findings.json`, `report.md`, `report.html` | `html-visual.md` | +| Cursor Canvas report | Active host is Cursor | renderer-owned `findings.json`, `canvas.json`, `report.canvas.tsx` | `cursor-canvas.md` | +| Portable HTML report | Active host is Claude Code, Codex, Qwen Code, GitHub Copilot, Pi, or WorkBuddy, or a portable visual is explicitly requested | renderer-owned `findings.json`, `report.md`, `report.html` | `html-visual.md` | | Markdown only | Markdown without a visual companion is explicitly requested | `report.md`, `findings.json` | none | | Inline only | Inline or no-files output is explicitly requested | none; inline analysis writes nothing | none | diff --git a/test/agent-customize-architecture.test.mjs b/test/agent-customize-architecture.test.mjs index f0fcee2..2f3fe1e 100644 --- a/test/agent-customize-architecture.test.mjs +++ b/test/agent-customize-architecture.test.mjs @@ -56,7 +56,7 @@ test("host architecture docs keep matrix, providers, and thin shells separate", assert.match(adapterReadme, /Codex \| Analysis-capable source-local host \| `\.codex-plugin\/`/u); assert.match(adapterReadme, /npm package includes six filesystem metadata\s+roots for Qoder, Claude Code, Codex, Cursor, Qwen, and Copilot, plus Pi install\s+metadata in the existing `package\.json`/u); assert.match(adapterReadme, /generated\s+Qoder runtime bundle\s+includes only the Qoder shell/u); - assert.match(adapterReadme, /Cursor \| Analysis-capable source-local host[^\n]+platforms\/cursor\.mjs/u); + assert.match(adapterReadme, /Cursor \| Canvas-capable source-local host[^\n]+platforms\/cursor\.mjs/u); assert.doesNotMatch(adapterReadme, /Cursor has no session-evidence adapter/u); assert.match(adapterReadme, /Split a host into `docs\/adapters\/\.md` only when/u); assert.match(adapterReadme, /Canonical product\s+judgment stays in `skills\/`/u); @@ -64,6 +64,7 @@ test("host architecture docs keep matrix, providers, and thin shells separate", assert.match(directoryAdr, /\.cursor-plugin\/\s+# \[active\] thin Cursor shell/u); assert.match(directoryAdr, /\.codex-plugin\/\s+# \[active\] thin Codex shell/u); assert.match(directoryAdr, /qoder-canvas\.md\s+# \[active\] Qoder Canvas output contract/u); + assert.match(directoryAdr, /cursor-canvas\.md\s+# \[active\] Cursor Canvas output contract/u); assert.match(directoryAdr, /npm-package\/\s+# \[active\] current bundle/u); assert.match(directoryAdr, /Host matrix entry\s+\| `docs\/adapters\/README\.md`/u); assert.match(directoryAdr, /build-host-plugin\.mjs\s+# assemble an existing thin host shell/u); diff --git a/test/better-harness-skill.test.mjs b/test/better-harness-skill.test.mjs index 7cb40c4..229ba10 100644 --- a/test/better-harness-skill.test.mjs +++ b/test/better-harness-skill.test.mjs @@ -76,26 +76,26 @@ test("Better Harness keeps one compact five-step owner", () => { assert.match(skill, /separate independent post-fix agent/); }); -test("non-Qoder providers default to validated durable HTML with an explicit inline opt-out", () => { +test("Qoder and Cursor default to Canvas while portable providers retain durable HTML", () => { const skill = read("skills/better-harness/SKILL.md"); const routing = read("templates/reporting/routing.md"); const adapters = read("docs/adapters/README.md"); const readme = read("README.md"); - assert.match(skill, /Default Qoder to durable Canvas and other providers to durable HTML/); + assert.match(skill, /Default Qoder\/Cursor to durable Canvas; default others to durable HTML/); assert.match(skill, /explicit inline or no-files request writes nothing/); assert.match(skill, /Other providers: =html; =\/\.\/better-harness/); assert.doesNotMatch(skill, /(?:Claude Code|Codex): =html/); - assert.match(skill, /HTML artifacts: findings\.json, report\.md, report\.html/); assert.match(skill, /Succeed only on\s+`status: pass`/); assert.match(skill, /Never hand-write\s+Canvas, Markdown, or HTML/); assert.match( routing, - /Portable HTML report \| Active host is Claude Code, Codex, Cursor, Qwen Code, GitHub Copilot, Pi, or WorkBuddy, or a portable visual is explicitly requested \|/, + /Portable HTML report \| Active host is Claude Code, Codex, Qwen Code, GitHub Copilot, Pi, or WorkBuddy, or a portable visual is explicitly requested \|/, ); + assert.match(routing, /Cursor Canvas report \| Active host is Cursor \|/); assert.match(routing, /Inline only \| Inline or no-files output is explicitly requested \| none; inline analysis writes nothing/); assert.match(adapters, /Claude Code[^\n]+scripts\/session-analysis\/platforms\/claude\.mjs[^\n]+self-contained HTML \+ Markdown/); - assert.match(adapters, /Cursor[^\n]+scripts\/session-analysis\/platforms\/cursor\.mjs[^\n]+self-contained HTML \+ Markdown/); + assert.match(adapters, /Cursor[^\n]+scripts\/session-analysis\/platforms\/cursor\.mjs[^\n]+cursor-canvas/); assert.match(adapters, /Pi[^\n]+scripts\/session-analysis\/platforms\/pi\.mjs[^\n]+self-contained HTML \+ Markdown/); assert.doesNotMatch(adapters, /Inline repository review only|No Claude\s+session-evidence adapter/); assert.match(readme, /Claude Code defaults to a self-contained `report\.html`/); diff --git a/test/docs-entrypoints.test.mjs b/test/docs-entrypoints.test.mjs index 2bec2bb..a1c887d 100644 --- a/test/docs-entrypoints.test.mjs +++ b/test/docs-entrypoints.test.mjs @@ -300,9 +300,10 @@ test("public adapter matrix documents all eight adapters and their support bound assert.match( matrix, - /Claude Code\/Codex\/Cursor\/Qwen\/Copilot\/Pi\/WorkBuddy/u, + /Claude Code\/Codex\/Qwen\/Copilot\/Pi\/WorkBuddy/u, "docs/docs/hosts/adapter-matrix.md HTML visual contract omits supported HTML hosts", ); + assert.match(matrix, /\*\*Cursor Canvas\*\*[^]*`cursor\/canvas`/u); assert.match(matrix, /### Pi \{#pi\}/u); assert.match(matrix, /### WorkBuddy \{#workbuddy\}/u); }); @@ -324,9 +325,10 @@ test("zh-Hans public adapter matrix documents all eight adapters and their suppo assert.match( matrix, - /Claude Code\/Codex\/Cursor\/Qwen\/Copilot\/Pi\/WorkBuddy/u, + /Claude Code\/Codex\/Qwen\/Copilot\/Pi\/WorkBuddy/u, "zh-Hans adapter-matrix.md HTML visual contract omits supported HTML hosts", ); + assert.match(matrix, /\*\*Cursor Canvas\*\*[^]*`cursor\/canvas`/u); assert.match(matrix, /### Pi \{#pi\}/u); assert.match(matrix, /### WorkBuddy \{#workbuddy\}/u); }); diff --git a/test/harness-report-run.test.mjs b/test/harness-report-run.test.mjs index e7a302e..b276ada 100644 --- a/test/harness-report-run.test.mjs +++ b/test/harness-report-run.test.mjs @@ -220,7 +220,7 @@ test("analyze can initialize machine-owned Canvas summary facts", async () => { }); }); -test("analyze Canvas output requires Qoder JSON and the canonical filename", async () => { +test("analyze Canvas output requires Qoder or Cursor JSON and the canonical filename", async () => { const sourceInput = candidateSourceWithUsage("/workspace/example"); await assert.rejects( analyzeHarnessEvidence({ @@ -238,7 +238,7 @@ test("analyze Canvas output requires Qoder JSON and the canonical filename", asy format: "json", "canvas-out": "/tmp/canvas.json", }), - /supported only for the Better Harness bundle/u, + /supported for Qoder and Cursor Canvas bundles/u, ); await assert.rejects( analyzeHarnessEvidence({ diff --git a/test/scripts-refactor-contract.test.mjs b/test/scripts-refactor-contract.test.mjs index 1dc3155..ae1160a 100644 --- a/test/scripts-refactor-contract.test.mjs +++ b/test/scripts-refactor-contract.test.mjs @@ -107,17 +107,17 @@ test("scripts refactor contract freezes machine-readable CLI output", () => { { label: "command inventory", args: ["commands", "--json"], - sha256: "e86c8b3447f24cf1f6a94f7ca74c48c5d194aa54bad736ff02d7dd64e566dc99", + sha256: "7ed9c1399e89ca29c888ab17e04cb023d3f15f7dfc819a989de0015ed19a87bf", }, { label: "OpenCLI schema", args: ["schema"], - sha256: "b6b28fede8f135e598bc2278a36d2c94660edfe1b384c14f6e5da73c394a3347", + sha256: "4afc57251605333b3a9fc1ccb244fb6a60a34a7ed9d5cabde307fe636ba1e0a5", }, { label: "Harness command description", args: ["command", "describe", "harness", "--json"], - sha256: "7bfdf90239d7c021dc97375a6b82c40ea435847fe57e2774fcdc56ec7c56bacd", + sha256: "a2562f1719b53a24c9e1a39d9fe32924ad5a1c38253177a2dc6f5970461b9303", }, ]; diff --git a/test/style-templates.test.mjs b/test/style-templates.test.mjs index efd98d6..3b31a13 100644 --- a/test/style-templates.test.mjs +++ b/test/style-templates.test.mjs @@ -74,10 +74,11 @@ test("harness report routing owns output-mode selection and exclusions", () => { assert.match(projectTraits, /## Output Shape/); assert.match(reportRouting, /Choose exactly one output route/); assert.match(reportRouting, /Qoder Canvas report/); + assert.match(reportRouting, /Cursor Canvas report/); assert.match(reportRouting, /Portable HTML report/); assert.match( reportRouting, - /Active host is Claude Code, Codex, Cursor, Qwen Code, GitHub Copilot, Pi, or WorkBuddy, or a portable visual is explicitly requested \|/, + /Active host is Claude Code, Codex, Qwen Code, GitHub Copilot, Pi, or WorkBuddy, or a portable visual is explicitly requested \|/, ); assert.match(reportRouting, /Markdown only/); assert.match(reportRouting, /Inline only/); From f5224b6e3fd05358040cc0f14483ba3c7395f7b7 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Sat, 1 Aug 2026 15:32:23 +0800 Subject: [PATCH 2/2] fix(cursor): keep excluded context files out of report labels `contextItemLabel` fell back to the native `source.label` even when `safeContextSource` had rejected that source for living outside the workspace, so a file the boundary just excluded could still name a Context Window row. Only an admitted source may label an item now. Report the `cursor-context-usage` source from a materialized snapshot file instead of its parent `canvases` directory, which can exist while holding no evidence. A workspace without a snapshot now reports `exists: false` and raises the normal `missing-optional-root` warning rather than claiming a present source next to an unobserved projection. Drop the unresolvable `parentId` projection and its schema slot, since native parent ids name synthetic group nodes that are absent from `items` and no reader surface consumed the field. Give each Canvas mode its own analyzer companion filename so the Cursor route no longer borrows the Qoder constant. Add `test/cursor-canvas-report.test.mjs` covering the bounded projection, the workspace source boundary, the unobserved and fail-closed paths, the snapshot presence contract, the Context Usage schema bounds, the renderer, the validator, and a `--mode cursor-canvas --validate` run. The label leak surfaced only while writing that coverage. The per-route artifact inventory stays asserted in `templates/reporting/routing.md`, which owns it, because the root Skill sits against a hard 12,000-byte prompt budget. Validated with npm test (1,061 passing), npm run pack:verify, and real Cursor scans of both an observed native snapshot and an unobserved workspace. Co-authored-by: QoderAI --- README.md | 6 +- docs/docs/your-first-report.md | 3 +- scripts/harness-analysis/render-report.mjs | 12 +- scripts/harness-analysis/task-loop-report.mjs | 2 +- scripts/session-analysis/platforms/cursor.mjs | 52 ++- test/better-harness-skill.test.mjs | 4 + test/cursor-canvas-report.test.mjs | 431 ++++++++++++++++++ 7 files changed, 485 insertions(+), 25 deletions(-) create mode 100644 test/cursor-canvas-report.test.mjs diff --git a/README.md b/README.md index cd3fb44..158c9b4 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,9 @@ for their explicit boundaries. Better Harness scopes behavior claims to relevant Task Episodes and the surrounding project mechanisms. Qoder and Cursor produce host-native Canvas reports; Claude Code, Codex, Qwen Code, and GitHub Copilot produce -self-contained HTML with paired Markdown. Missing or partial evidence remains explicit. See the -[Host Adapter Matrix](docs/adapters/README.md) for current coverage and output -differences. +self-contained HTML with paired Markdown. Missing or partial evidence remains +explicit. See the [Host Adapter Matrix](docs/adapters/README.md) for current +coverage and output differences. ## See it in action diff --git a/docs/docs/your-first-report.md b/docs/docs/your-first-report.md index 7f18f3d..9c1b7a6 100644 --- a/docs/docs/your-first-report.md +++ b/docs/docs/your-first-report.md @@ -21,7 +21,8 @@ your host's **Verify installation** section—the syntax is host-specific: Better Harness scopes behavior claims to relevant Task Episodes and the surrounding project mechanisms. Qoder and Cursor produce host-native Canvas reports; Claude Code, Codex, Qwen Code, and GitHub Copilot produce -self-contained HTML with paired Markdown. Missing or partial evidence remains explicit. +self-contained HTML with paired Markdown. Missing or partial evidence remains +explicit. See the [sample report](pathname:///demo/better-harness-report/) for what the HTML output looks like. diff --git a/scripts/harness-analysis/render-report.mjs b/scripts/harness-analysis/render-report.mjs index 5109b0f..0d13498 100644 --- a/scripts/harness-analysis/render-report.mjs +++ b/scripts/harness-analysis/render-report.mjs @@ -41,6 +41,13 @@ function filesystemPathIdentity(value) { return process.platform === "win32" ? normalized.toLowerCase() : normalized; } +// Each Canvas mode owns its own analyzer companion filename so the two routes +// stay independent even though they currently agree on `canvas.json`. +const ANALYZER_CANVAS_DATA_FILE_BY_MODE = Object.freeze({ + "qoder-canvas": QODER_CANVAS_DATA_FILE, + "cursor-canvas": CURSOR_CANVAS_DATA_FILE, +}); + const HELP = `Usage: better-harness harness render (--source | --findings ) --mode --out --target [options] Render reviewed findings data into deterministic report artifacts. @@ -105,10 +112,11 @@ function artifact(name, runDir) { } function implicitAnalyzerCanvasPath(options, findingsPath) { - if (options.canvas || !new Set(["qoder-canvas", "cursor-canvas"]).has(options.mode)) { + const canvasDataFile = ANALYZER_CANVAS_DATA_FILE_BY_MODE[options.mode]; + if (options.canvas || !canvasDataFile) { return null; } - const candidatePath = path.join(path.dirname(findingsPath), QODER_CANVAS_DATA_FILE); + const candidatePath = path.join(path.dirname(findingsPath), canvasDataFile); if (!existsSync(candidatePath)) return null; const candidate = readJsonFile(candidatePath); return Object.hasOwn(candidate ?? {}, "summaryFactsSchemaVersion") ? candidatePath : null; diff --git a/scripts/harness-analysis/task-loop-report.mjs b/scripts/harness-analysis/task-loop-report.mjs index 2b7e20e..77aa8c0 100644 --- a/scripts/harness-analysis/task-loop-report.mjs +++ b/scripts/harness-analysis/task-loop-report.mjs @@ -3071,7 +3071,7 @@ function contextUsageErrors(usage) { errors.push(`${itemPrefix} must be an object`); continue; } - errors.push(...unsupportedFields(item, ["id", "parentId", "categoryId", "label", "estimatedTokens", "characterCount", "source"], itemPrefix)); + errors.push(...unsupportedFields(item, ["id", "categoryId", "label", "estimatedTokens", "characterCount", "source"], itemPrefix)); for (const field of ["id", "categoryId", "label"]) { if (typeof item[field] !== "string" || !item[field].trim()) errors.push(`${itemPrefix}.${field} must be non-empty text`); } diff --git a/scripts/session-analysis/platforms/cursor.mjs b/scripts/session-analysis/platforms/cursor.mjs index 5ad1d1c..354f060 100644 --- a/scripts/session-analysis/platforms/cursor.mjs +++ b/scripts/session-analysis/platforms/cursor.mjs @@ -60,7 +60,9 @@ function safeContextSource(source, scope) { } function contextItemLabel(item, index, admittedSource) { - const sourceLabel = boundedText(admittedSource?.label ?? item?.source?.label, 160); + // Only an admitted source may name the item; a rejected source's label would + // re-expose a file the workspace boundary just excluded. + const sourceLabel = boundedText(admittedSource?.label, 160); if (sourceLabel && !path.isAbsolute(sourceLabel)) return sourceLabel; const candidate = boundedText(item?.label, 240); if (!candidate) return `Context item ${index + 1}`; @@ -78,15 +80,10 @@ function projectContextUsageSnapshot(raw, { capturedAt, scope } = {}) { if (contextWindowSize <= 0 || totalTokensUsed <= 0) return null; const rawItems = Array.isArray(usage.items) ? usage.items.slice(0, CURSOR_CONTEXT_USAGE_ITEM_LIMIT) : []; - const itemIds = new Map(rawItems.map((item, index) => [String(item?.id ?? `item-${index + 1}`), `item-${index + 1}`])); const items = rawItems.map((item, index) => { const source = safeContextSource(item?.source, scope); - const parentId = item?.parentId === undefined || item?.parentId === null - ? null - : itemIds.get(String(item.parentId)) ?? null; return { id: `item-${index + 1}`, - parentId, categoryId: boundedText(item?.categoryId, 80) || "other", label: contextItemLabel(item, index, source), estimatedTokens: nonNegativeInteger(item?.estimatedTokens), @@ -123,11 +120,15 @@ function projectContextUsageSnapshot(raw, { capturedAt, scope } = {}) { }; } -export async function readCursorContextUsage(scope) { - const canvasRoots = scope._workspaceSlugVariants - .map((slug) => path.join(scope.home, "projects", slug, "canvases")); +export function cursorContextUsageCanvasRoots(scope) { + return scope._workspaceSlugVariants.map((slug) => path.join(scope.home, "projects", slug, "canvases")); +} + +// A `canvases` directory is not itself Context Usage evidence; only a materialized +// snapshot file is, so presence is reported from the files rather than the parent. +export async function findCursorContextUsageSnapshots(scope) { const candidates = []; - for (const root of canvasRoots) { + for (const root of cursorContextUsageCanvasRoots(scope)) { if (!await pathExists(root)) continue; const files = await walkFiles(root, { maxDepth: 1, @@ -137,12 +138,20 @@ export async function readCursorContextUsage(scope) { for (const filePath of files) { let metadata; try { metadata = await stat(filePath); } catch { continue; } - const capturedAt = new Date(metadata.mtimeMs).toISOString(); - if (!withinTimeRange(capturedAt, scope)) continue; - candidates.push({ filePath, capturedAt, mtimeMs: metadata.mtimeMs }); + candidates.push({ + filePath, + capturedAt: new Date(metadata.mtimeMs).toISOString(), + mtimeMs: metadata.mtimeMs, + }); } } candidates.sort((left, right) => right.mtimeMs - left.mtimeMs || left.filePath.localeCompare(right.filePath)); + return candidates; +} + +export async function readCursorContextUsage(scope) { + const snapshots = await findCursorContextUsageSnapshots(scope); + const candidates = snapshots.filter((candidate) => withinTimeRange(candidate.capturedAt, scope)); for (const candidate of candidates) { let raw; try { raw = await readJson(candidate.filePath); } catch { continue; } @@ -597,6 +606,8 @@ export class CursorSessionAnalyzer extends SessionAnalyzer { async discoverSourceRoots(scope) { const transcriptPaths = scope._workspaceSlugVariants .map((slug) => path.join(scope.home, "projects", slug, "agent-transcripts")); + const contextUsagePaths = cursorContextUsageCanvasRoots(scope); + const contextUsageSnapshots = await findCursorContextUsageSnapshots(scope); const roots = [ { id: "cursor-agent-transcripts", @@ -623,12 +634,15 @@ export class CursorSessionAnalyzer extends SessionAnalyzer { id: "cursor-context-usage", kind: "cursor-context-usage-canvas", role: "context-usage-snapshot", - path: path.join(scope.home, "projects", scope._workspaceSlugVariants[0], "canvases"), - paths: scope._workspaceSlugVariants.map((slug) => path.join(scope.home, "projects", slug, "canvases")), + path: contextUsageSnapshots[0]?.filePath ?? contextUsagePaths[0], + paths: contextUsagePaths, optional: true, enabled: true, workspaceScoped: true, coverage: "optional-context-usage", + // The parent `canvases` directory can exist without any snapshot, so + // presence tracks a materialized snapshot file instead of the directory. + exists: contextUsageSnapshots.length > 0, }, { id: "cursor-audit", @@ -653,9 +667,11 @@ export class CursorSessionAnalyzer extends SessionAnalyzer { ]; return Promise.all(roots.map(async (root) => ({ ...root, - exists: root.paths - ? (await Promise.all(root.paths.map(pathExists))).some(Boolean) - : await pathExists(root.path), + exists: Object.hasOwn(root, "exists") + ? root.exists + : root.paths + ? (await Promise.all(root.paths.map(pathExists))).some(Boolean) + : await pathExists(root.path), }))); } diff --git a/test/better-harness-skill.test.mjs b/test/better-harness-skill.test.mjs index 229ba10..5bab840 100644 --- a/test/better-harness-skill.test.mjs +++ b/test/better-harness-skill.test.mjs @@ -86,6 +86,10 @@ test("Qoder and Cursor default to Canvas while portable providers retain durable assert.match(skill, /explicit inline or no-files request writes nothing/); assert.match(skill, /Other providers: =html; =\/\.\/better-harness/); assert.doesNotMatch(skill, /(?:Claude Code|Codex): =html/); + // The per-route artifact inventory is owned by routing.md, not duplicated in the + // byte-budgeted root Skill. + assert.match(routing, /renderer-owned `findings\.json`, `canvas\.json`, `report\.canvas\.tsx` \| `cursor-canvas\.md`/); + assert.match(routing, /renderer-owned `findings\.json`, `report\.md`, `report\.html` \| `html-visual\.md`/); assert.match(skill, /Succeed only on\s+`status: pass`/); assert.match(skill, /Never hand-write\s+Canvas, Markdown, or HTML/); assert.match( diff --git a/test/cursor-canvas-report.test.mjs b/test/cursor-canvas-report.test.mjs new file mode 100644 index 0000000..e46b7a0 --- /dev/null +++ b/test/cursor-canvas-report.test.mjs @@ -0,0 +1,431 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { renderCursorCanvasTsx } from "../scripts/harness-analysis/renderers/cursor-canvas.mjs"; +import { + projectTaskLoopReportFacts, + reconcileTaskLoopFindingLinks, + splitTaskLoopFindings, + taskLoopCanvasFromSummaryFacts, + validateTaskLoopCanvasSplit, +} from "../scripts/harness-analysis/task-loop-report.mjs"; +import { buildTaskLoopRepositoryEvidence } from "../scripts/harness-analysis/task-loop-repository-evidence.mjs"; +import { buildTaskLoopSourceCandidate } from "../scripts/harness-analysis/task-loop-source.mjs"; +import { validateCursorCanvasArtifacts } from "../scripts/harness-analysis/validate-cursor-canvas.mjs"; +import { + CursorSessionAnalyzer, + readCursorContextUsage, + workspaceToCursorSlugVariants, +} from "../scripts/session-analysis/platforms/cursor.mjs"; + +const cliPath = path.join(process.cwd(), "scripts", "better-harness.mjs"); +const RAW_ITEM_TEXT = "RAW-PROMPT-TEXT-THAT-MUST-NOT-BE-RETAINED"; + +async function withTempDir(name, fn) { + const root = await mkdtemp(path.join(os.tmpdir(), name)); + try { + return await fn(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function writeJson(filePath, value) { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function canvasesDir(cursorHome, workspace) { + return path.join(cursorHome, "projects", workspaceToCursorSlugVariants(workspace)[0], "canvases"); +} + +function nativeSnapshot(workspace, outsidePath) { + return { + contextUsage: { + contextWindowSize: 300_000, + totalTokensUsed: 60_000, + composerId: "composer-1", + contextWindowLabel: "300K", + categories: [ + { id: "rules", label: "Rules", tokens: 1_000, color: "green" }, + { id: "mcp", label: "MCP", tokens: 2_000, color: "pink" }, + { id: "unused", label: "Unused", tokens: 0, color: "gray" }, + ], + items: [ + { + id: "native:rules:1", + parentId: "group:rules", + categoryId: "rules", + label: path.join(workspace, "AGENTS.md"), + text: RAW_ITEM_TEXT, + estimatedTokens: 500, + characterCount: 2_000, + source: { kind: "file", path: path.join(workspace, "AGENTS.md"), label: "AGENTS" }, + }, + { + id: "native:mcp:1", + categoryId: "mcp", + label: "app-control", + text: RAW_ITEM_TEXT, + estimatedTokens: 300, + characterCount: 1_200, + source: { kind: "file", path: outsidePath, label: "Outside" }, + }, + { + id: "native:rules:2", + categoryId: "rules", + label: path.join(outsidePath, "deep", "reference.md"), + estimatedTokens: 10, + characterCount: 40, + }, + ], + }, + }; +} + +async function cursorScope(cursorHome, workspace) { + return new CursorSessionAnalyzer().resolveScope({ workspace, home: cursorHome }); +} + +function reviewedFindingsInput(contextUsage) { + const input = JSON.parse(readFileSync( + path.join(process.cwd(), "templates", "reporting", "harness-findings.input.json"), + "utf8", + )); + if (contextUsage) input.summary.contextUsage = contextUsage; + // Mirror the render pipeline, which reconciles finding links before splitting. + return reconcileTaskLoopFindingLinks(input); +} + +function observedContextUsage(overrides = {}) { + return { + schemaVersion: 1, + status: "observed", + evidence: "cursor-native-context-usage-canvas", + capturedAt: "2026-07-30T00:00:00.000Z", + totalTokensUsed: 60_000, + contextWindowSize: 300_000, + percentFull: 20, + categories: [{ id: "rules", label: "Rules", estimatedTokens: 1_000 }], + items: [{ id: "item-1", categoryId: "rules", label: "AGENTS", estimatedTokens: 500, characterCount: 2_000 }], + coverage: { snapshotCount: 1, itemCount: 1, sourceItemCount: 1, truncated: false, rawTextOmitted: true }, + actions: { openAgentId: "composer-1" }, + ...overrides, + }; +} + +test("Cursor context usage projects bounded native evidence and omits raw item text", async () => { + await withTempDir("cursor-context-usage-observed-", async (root) => { + const workspace = path.join(root, "workspace"); + const outside = path.join(root, "other-repo"); + const cursorHome = path.join(root, ".cursor"); + await mkdir(workspace, { recursive: true }); + await mkdir(outside, { recursive: true }); + await writeJson( + path.join(canvasesDir(cursorHome, workspace), "context-usage-abc.canvas.data.json"), + nativeSnapshot(workspace, outside), + ); + + const usage = await readCursorContextUsage(await cursorScope(cursorHome, workspace)); + + assert.equal(usage.status, "observed"); + assert.equal(usage.schemaVersion, 1); + assert.equal(usage.evidence, "cursor-native-context-usage-canvas"); + assert.equal(usage.totalTokensUsed, 60_000); + assert.equal(usage.contextWindowSize, 300_000); + assert.equal(usage.percentFull, 20); + assert.equal(usage.actions.openAgentId, "composer-1"); + + assert.deepEqual(usage.categories.map((category) => category.id), ["rules", "mcp"]); + assert.equal(usage.items.length, 3); + assert.deepEqual(usage.items.map((item) => item.id), ["item-1", "item-2", "item-3"]); + assert.deepEqual(usage.coverage, { + itemCount: 3, + sourceItemCount: 3, + truncated: false, + rawTextOmitted: true, + snapshotCount: 1, + }); + + const serialized = JSON.stringify(usage); + assert.doesNotMatch(serialized, new RegExp(RAW_ITEM_TEXT, "u"), "raw native item text must never be retained"); + assert.equal(serialized.includes("\"text\":"), false); + assert.equal(serialized.includes("\"parentId\""), false, "unresolvable native hierarchy must not be carried as dead data"); + assert.equal(usage.items.every((item) => !path.isAbsolute(item.label)), true); + }); +}); + +test("Cursor context usage admits only workspace-local file sources", async () => { + await withTempDir("cursor-context-usage-sources-", async (root) => { + const workspace = path.join(root, "workspace"); + const outside = path.join(root, "other-repo"); + const cursorHome = path.join(root, ".cursor"); + await mkdir(workspace, { recursive: true }); + await mkdir(outside, { recursive: true }); + await writeJson( + path.join(canvasesDir(cursorHome, workspace), "context-usage-abc.canvas.data.json"), + nativeSnapshot(workspace, outside), + ); + + const usage = await readCursorContextUsage(await cursorScope(cursorHome, workspace)); + const [insideItem, outsideItem, labelOnlyItem] = usage.items; + + assert.deepEqual(insideItem.source, { + kind: "file", + path: path.join(workspace, "AGENTS.md"), + label: "AGENTS", + }); + assert.equal(insideItem.label, "AGENTS"); + assert.equal(Object.hasOwn(outsideItem, "source"), false, "a file outside the workspace must not become an openFile target"); + assert.equal(outsideItem.label, "app-control"); + assert.equal(Object.hasOwn(labelOnlyItem, "source"), false); + assert.equal(labelOnlyItem.label, "deep/reference.md", "an absolute label must collapse to bounded parent/base text"); + }); +}); + +test("Cursor context usage reports unobserved without inventing zero usage", async () => { + await withTempDir("cursor-context-usage-unobserved-", async (root) => { + const workspace = path.join(root, "workspace"); + const cursorHome = path.join(root, ".cursor"); + await mkdir(workspace, { recursive: true }); + await mkdir(canvasesDir(cursorHome, workspace), { recursive: true }); + + const usage = await readCursorContextUsage(await cursorScope(cursorHome, workspace)); + + assert.equal(usage.status, "unobserved"); + assert.deepEqual(usage.categories, []); + assert.deepEqual(usage.items, []); + assert.equal(usage.coverage.snapshotCount, 0); + assert.equal(usage.coverage.rawTextOmitted, true); + assert.equal(usage.actions.openAgentId, null); + assert.equal(Object.hasOwn(usage, "totalTokensUsed"), false); + assert.equal(Object.hasOwn(usage, "percentFull"), false); + }); +}); + +test("Cursor context usage fails closed on a malformed native snapshot", async () => { + await withTempDir("cursor-context-usage-malformed-", async (root) => { + const workspace = path.join(root, "workspace"); + const cursorHome = path.join(root, ".cursor"); + await mkdir(workspace, { recursive: true }); + const canvases = canvasesDir(cursorHome, workspace); + await writeFile(path.join(await mkdirp(canvases), "context-usage-broken.canvas.data.json"), "{ not json"); + await writeJson(path.join(canvases, "context-usage-zero.canvas.data.json"), { + contextUsage: { contextWindowSize: 0, totalTokensUsed: 0, categories: [], items: [] }, + }); + + const usage = await readCursorContextUsage(await cursorScope(cursorHome, workspace)); + + assert.equal(usage.status, "unobserved"); + assert.equal(usage.coverage.snapshotCount, 2, "unusable snapshots stay visible as inspected candidates"); + assert.deepEqual(usage.items, []); + }); + + async function mkdirp(dir) { + await mkdir(dir, { recursive: true }); + return dir; + } +}); + +test("Cursor context-usage source presence tracks a snapshot file, not its parent directory", async () => { + await withTempDir("cursor-context-usage-root-", async (root) => { + const workspace = path.join(root, "workspace"); + const cursorHome = path.join(root, ".cursor"); + await mkdir(workspace, { recursive: true }); + const canvases = canvasesDir(cursorHome, workspace); + await mkdir(canvases, { recursive: true }); + + const analyzer = new CursorSessionAnalyzer(); + const scope = await cursorScope(cursorHome, workspace); + const emptyRoots = await analyzer.discoverSourceRoots(scope); + const emptyRoot = emptyRoots.find((entry) => entry.id === "cursor-context-usage"); + + assert.equal(emptyRoot.optional, true); + assert.equal(emptyRoot.workspaceScoped, true); + assert.equal(emptyRoot.exists, false, "an empty canvases directory is not Context Usage evidence"); + assert.equal(emptyRoot.path, canvases); + + const snapshotPath = path.join(canvases, "context-usage-abc.canvas.data.json"); + await writeJson(snapshotPath, nativeSnapshot(workspace, path.join(root, "other-repo"))); + const populatedRoots = await analyzer.discoverSourceRoots(scope); + const populatedRoot = populatedRoots.find((entry) => entry.id === "cursor-context-usage"); + + assert.equal(populatedRoot.exists, true); + assert.equal(populatedRoot.path, snapshotPath, "the source path names the observed snapshot"); + }); +}); + +test("Canvas split validation bounds the Context Usage contract", () => { + const split = splitTaskLoopFindings(reviewedFindingsInput(observedContextUsage())); + assert.equal(Object.hasOwn(split.findings.summary, "contextUsage"), false); + assert.deepEqual(validateTaskLoopCanvasSplit(split.findings, split.canvas), []); + + const withDeadHierarchy = structuredClone(split.canvas); + withDeadHierarchy.summary.contextUsage.items[0].parentId = "item-0"; + assert.match( + validateTaskLoopCanvasSplit(split.findings, withDeadHierarchy).join("; "), + /contextUsage\.items\[0\] has unsupported field: parentId/u, + ); + + const withRawText = structuredClone(split.canvas); + withRawText.summary.contextUsage.items[0].text = RAW_ITEM_TEXT; + assert.match( + validateTaskLoopCanvasSplit(split.findings, withRawText).join("; "), + /contextUsage\.items\[0\] has unsupported field: text/u, + ); + + const withoutOmissionClaim = structuredClone(split.canvas); + withoutOmissionClaim.summary.contextUsage.coverage.rawTextOmitted = false; + assert.match( + validateTaskLoopCanvasSplit(split.findings, withoutOmissionClaim).join("; "), + /contextUsage\.coverage\.rawTextOmitted must be true/u, + ); + + const unobservedWithClaims = structuredClone(split.canvas); + unobservedWithClaims.summary.contextUsage.status = "unobserved"; + assert.match( + validateTaskLoopCanvasSplit(split.findings, unobservedWithClaims).join("; "), + /unobserved snapshots must not contain category or item claims/u, + ); + + const relativeSource = structuredClone(split.canvas); + relativeSource.summary.contextUsage.items[0].source = { kind: "file", path: "AGENTS.md" }; + assert.match( + validateTaskLoopCanvasSplit(split.findings, relativeSource).join("; "), + /contextUsage\.items\[0\]\.source must identify an absolute local file/u, + ); +}); + +test("Cursor Canvas renderer embeds the merged report behind the public SDK surface", () => { + const split = splitTaskLoopFindings(reviewedFindingsInput(observedContextUsage())); + const source = renderCursorCanvasTsx({ + summary: { ...split.findings.summary, ...split.canvas.summary }, + findings: split.findings.findings, + target: "/workspace/example", + }); + + assert.equal(source.includes("__BETTER_HARNESS_REPORT__"), false, "the data placeholder must be replaced"); + assert.match(source, /from "cursor\/canvas"/u); + assert.equal(source.includes("qoder/canvas"), false); + assert.match(source, /"contextUsage":\{"schemaVersion":1,"status":"observed"/u); + for (const section of ["FluencyDimensions", "ProjectUsage", "AgentPractice", "ContextWindow", "Findings", "EvidenceAndMethodology"]) { + assert.match(source, new RegExp(`function ${section}`, "u")); + } + for (const action of ["newComposerChat", "openFile", "openAgent"]) { + assert.match(source, new RegExp(`type: "${action}"`, "u")); + } +}); + +test("Cursor Canvas validation passes a rendered bundle and rejects a foreign SDK import", async () => { + await withTempDir("cursor-canvas-validate-", async (root) => { + const split = splitTaskLoopFindings(reviewedFindingsInput(observedContextUsage())); + const canvasPath = path.join(root, "report.canvas.tsx"); + const findingsPath = path.join(root, "findings.json"); + const canvasDataPath = path.join(root, "canvas.json"); + await writeJson(findingsPath, split.findings); + await writeJson(canvasDataPath, split.canvas); + const source = renderCursorCanvasTsx({ + summary: { ...split.findings.summary, ...split.canvas.summary }, + findings: split.findings.findings, + target: "/workspace/example", + }); + await writeFile(canvasPath, source); + + const passing = await validateCursorCanvasArtifacts({ canvasPath, findingsPath, canvasDataPath }); + assert.equal(passing.status, "pass", passing.errors.join("; ")); + assert.deepEqual(passing.checks.map((entry) => entry.id), [ + "cursor-canvas-inputs", + "cursor-canvas-data", + "cursor-canvas-boundaries", + "cursor-canvas-content", + "cursor-canvas-transform", + ]); + const dataCheck = passing.checks.find((entry) => entry.id === "cursor-canvas-data"); + assert.equal(dataCheck.summary.contextUsageStatus, "observed"); + + await writeFile(canvasPath, source.replace("cursor/canvas", "qoder/canvas")); + const failing = await validateCursorCanvasArtifacts({ canvasPath, findingsPath, canvasDataPath }); + assert.equal(failing.status, "fail"); + assert.match(failing.errors.join("; "), /must not import qoder\/canvas/u); + assert.match(failing.errors.join("; "), /must import cursor\/canvas/u); + }); +}); + +// Build the analyzer-owned companion the way the Cursor scan does: source +// candidate -> exact summary facts -> canvas.json. +function analyzerCanvasWithContextUsage(workspace) { + const repositoryEvidence = buildTaskLoopRepositoryEvidence({ + trackedFiles: ["AGENTS.md", "package.json", "test/a.test.mjs"], + packageManifest: { scripts: { test: "node --test" } }, + }); + const source = buildTaskLoopSourceCandidate({ + scope: { platform: "cursor", workspace }, + selection: { strategy: "latest-n", eligibleCount: 0, analyzedCount: 0, strata: [] }, + events: [], + repositoryEvidence, + contextUsage: observedContextUsage(), + }); + return taskLoopCanvasFromSummaryFacts(projectTaskLoopReportFacts(source)); +} + +test("render --mode cursor-canvas merges analyzer Context Usage into exactly three artifacts", async () => { + await withTempDir("cursor-canvas-render-", async (root) => { + const target = path.join(root, "workspace"); + await mkdir(target, { recursive: true }); + const runInput = path.join(root, "input"); + const split = splitTaskLoopFindings(reviewedFindingsInput()); + await writeJson(path.join(runInput, "findings.json"), split.findings); + await writeJson(path.join(runInput, "canvas.json"), analyzerCanvasWithContextUsage(target)); + + const outRoot = path.join(root, "out"); + const result = spawnSync(process.execPath, [ + cliPath, "harness", "render", + "--findings", path.join(runInput, "findings.json"), + "--mode", "cursor-canvas", + "--out", outRoot, + "--run-dir", "run-1", + "--target", target, + "--validate", + "--json", + ], { encoding: "utf8" }); + + assert.equal(result.status, 0, `${result.stdout}${result.stderr}`); + const payload = JSON.parse(result.stdout); + assert.equal(payload.validation.status, "pass", JSON.stringify(payload.validation.errors)); + assert.deepEqual(payload.artifacts.map((entry) => entry.name).sort(), [ + "canvas.json", + "findings.json", + "report.canvas.tsx", + ]); + assert.deepEqual( + (await readdir(path.join(outRoot, "run-1"))).sort(), + ["canvas.json", "findings.json", "report.canvas.tsx"], + ); + + const merged = JSON.parse(readFileSync(path.join(outRoot, "run-1", "canvas.json"), "utf8")); + assert.equal(merged.summary.contextUsage.status, "observed"); + assert.equal(merged.summary.contextUsage.totalTokensUsed, 60_000); + const tsx = readFileSync(path.join(outRoot, "run-1", "report.canvas.tsx"), "utf8"); + assert.match(tsx, /"contextUsage":\{"schemaVersion":1,"status":"observed"/u); + assert.equal(tsx.includes("__BETTER_HARNESS_REPORT__"), false); + }); +}); + +test("render rejects a Cursor Canvas mode that is not part of the mode contract", () => { + const result = spawnSync(process.execPath, [ + cliPath, "harness", "render", + "--findings", path.join(process.cwd(), "templates", "reporting", "harness-findings.input.json"), + "--mode", "cursor-canvas-legacy", + "--out", path.join(os.tmpdir(), "cursor-canvas-unsupported"), + "--target", process.cwd(), + "--json", + ], { encoding: "utf8" }); + + assert.notEqual(result.status, 0); + assert.match(`${result.stdout}${result.stderr}`, /cursor-canvas/u); +});