diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json new file mode 100644 index 0000000..aad8bda --- /dev/null +++ b/.github/plugin/marketplace.json @@ -0,0 +1,27 @@ +{ + "name": "better-harness", + "owner": { + "name": "Qoder", + "email": "dev@qoder.com" + }, + "metadata": { + "description": "Better Harness plugins for AI delivery readiness.", + "version": "0.3.0" + }, + "plugins": [ + { + "name": "better-harness", + "description": "Build an AI-ready engineering system for safe coding-agent delivery and continuous software improvement.", + "version": "0.3.0", + "source": "./", + "author": { + "name": "Qoder", + "email": "dev@qoder.com" + }, + "homepage": "https://github.com/QoderAI/better-harness", + "repository": "https://github.com/QoderAI/better-harness", + "license": "MIT", + "skills": "./skills/" + } + ] +} diff --git a/.github/plugin/plugin.json b/.github/plugin/plugin.json new file mode 100644 index 0000000..f4d2eba --- /dev/null +++ b/.github/plugin/plugin.json @@ -0,0 +1,23 @@ +{ + "name": "better-harness", + "description": "Build an AI-ready engineering system for safe coding-agent delivery and continuous software improvement.", + "version": "0.3.0", + "author": { + "name": "Qoder", + "email": "dev@qoder.com", + "url": "https://qoder.com/" + }, + "homepage": "https://github.com/QoderAI/better-harness", + "repository": "https://github.com/QoderAI/better-harness", + "license": "MIT", + "keywords": [ + "copilot-plugin", + "better-harness", + "ai-delivery", + "continuous-improvement", + "agent-harness", + "change-confidence" + ], + "category": "Coding", + "skills": "./skills/" +} diff --git a/README.md b/README.md index db03eb5..19e00f5 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ Pick your coding agent — you can be looking at your first report in minutes: | **Codex Desktop** | Add the repository under **Settings > Plugins > + Add > From Marketplace**, install Better Harness, start a new task, then invoke `@better-harness`. | | **Codex CLI** | Add the Git marketplace, run `codex plugin add better-harness@better-harness`, then invoke `$better-harness:better-harness`. | | **Qoder Desktop / CLI** | Nothing to install when Qoder Desktop is installed — Better Harness is built in and available to both. Open your repository and use the report prompt below. | +| **GitHub Copilot CLI** | Add the repository marketplace, install `better-harness@better-harness`, start a new session, then use the report prompt below. | | **Cursor** | Load the plugin from source — see [Installation](#installation). | Once installed, ask Better Harness to generate the host's durable report: @@ -159,8 +160,8 @@ Once installed, ask Better Harness to generate the host's durable report: Better Harness scopes behavior claims to relevant Task Episodes and the surrounding project mechanisms. Qoder produces a Canvas report; Claude Code, -Codex, and Cursor produce self-contained HTML with paired Markdown. Missing or -partial evidence remains explicit. See the +Codex, Cursor, 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. @@ -309,6 +310,30 @@ cursor-agent --plugin-dir /path/to/better-harness Cursor session evidence is supported through workspace-matched transcripts, metadata, and audit logs. Partial or unavailable coverage remains explicit. +### GitHub Copilot + +Register this repository as a Copilot plugin marketplace, then install Better +Harness: + +```bash +copilot plugin marketplace add QoderAI/better-harness +copilot plugin install better-harness@better-harness +``` + +Verify that the Skill loaded: + +```bash +copilot plugin list +``` + +Prefer marketplace installs. Direct repository, URL, and local-path installs are +deprecated in Copilot CLI. + +Copilot session evidence is supported through workspace-matched Copilot CLI +transcripts under `~/.copilot/session-state/`. Copilot records no per-response +token usage, and VS Code Copilot Chat has no supported durable transcript; both +remain explicit evidence boundaries. + ## Develop and package from source Development requires Node.js `>=22.20.0 <25.0.0` and npm diff --git a/README.zh-CN.md b/README.zh-CN.md index cc19e34..d003748 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -145,6 +145,7 @@ Better Harness 开放了三个相互关联的层次,而不只是一个斜杠 | **Codex Desktop** | 在 **Settings > Plugins > + Add > From Marketplace** 中添加本仓库,安装 Better Harness,启动新任务,然后调用 `@better-harness`。 | | **Codex CLI** | 添加 Git Marketplace,运行 `codex plugin add better-harness@better-harness`,然后调用 `$better-harness:better-harness`。 | | **Qoder Desktop / CLI** | 安装 Qoder Desktop 后无需额外安装——Better Harness 已内置,并可在桌面端和 CLI 中使用。打开仓库并使用下方的报告提示词。 | +| **GitHub Copilot CLI** | 添加本仓库 Marketplace,安装 `better-harness@better-harness`,启动新会话,然后使用下方的报告提示词。 | | **Cursor** | 从源码加载插件——参见[安装](#installation)。 | 安装完成后,让 Better Harness 生成当前宿主支持的持久化报告: @@ -154,7 +155,7 @@ Better Harness 开放了三个相互关联的层次,而不只是一个斜杠 ``` Better Harness 会将行为断言限定在相关的任务过程片段(Task Episode)及其周边项目机制内。 -Qoder 生成 Canvas 报告;Claude Code、Codex 和 Cursor 生成自包含的 HTML 报告及配套 Markdown。 +Qoder 生成 Canvas 报告;Claude Code、Codex、Cursor、Qwen Code 和 GitHub Copilot 生成自包含的 HTML 报告及配套 Markdown。 缺失或不完整的证据会被明确标注。有关当前覆盖范围和输出差异,请参阅 [宿主适配器矩阵](docs/adapters/README.md)。 @@ -297,6 +298,27 @@ cursor-agent --plugin-dir /path/to/better-harness Cursor 会话证据来自与工作区匹配的会话记录、元数据和审计日志。 覆盖范围不完整或不可用时会被明确标注。 +### GitHub Copilot + +将本仓库注册为 Copilot 插件 Marketplace,然后安装 Better Harness: + +```bash +copilot plugin marketplace add QoderAI/better-harness +copilot plugin install better-harness@better-harness +``` + +验证 Skill 已加载: + +```bash +copilot plugin list +``` + +请优先使用 Marketplace 安装。Copilot CLI 已弃用直接从仓库、URL 或本地路径安装。 + +Copilot 会话证据来自 `~/.copilot/session-state/` 下与工作区匹配的 Copilot CLI 会话记录。 +Copilot 不记录逐次响应的 token 用量,VS Code Copilot Chat 也没有受支持的持久化会话记录; +两者均作为明确的证据边界保留。 + ## 从源码开发和打包 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a4397ec..f4ec0bb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -66,7 +66,7 @@ reference is `docs/adrs/directory-structure.md`. in the capability-owned agent-customize and session-analysis providers. The Codex shell owns local install/discovery metadata only; Codex evidence collection remains in the capability-owned provider and session-analysis modules. The public npm - package ships all five plugin metadata roots, while the Qoder runtime bundle + package ships all six plugin metadata roots, while the Qoder runtime bundle includes only `.qoder-plugin/`. ## Template Boundaries diff --git a/docs/adapters/README.md b/docs/adapters/README.md index cbc48d5..0a749f2 100644 --- a/docs/adapters/README.md +++ b/docs/adapters/README.md @@ -1,9 +1,9 @@ # Host Adapter Matrix -This is the single entry point for Claude Code, Codex, Qoder, Cursor, and Qwen -host boundaries. Do not create `docs/adapters/claude-code.md`, +This is the single entry point for Claude Code, Codex, Qoder, Cursor, Qwen, and +GitHub Copilot host boundaries. Do not create `docs/adapters/claude-code.md`, `docs/adapters/codex.md`, `docs/adapters/qoder.md`, `docs/adapters/cursor.md`, -or `docs/adapters/qwen.md` by default. +`docs/adapters/qwen.md`, or `docs/adapters/copilot.md` by default. Host differences enter only this matrix, capability-local configured-asset providers, real session-evidence adapters, and output modes. Canonical product @@ -11,10 +11,10 @@ judgment stays in `skills/`, `models/`, `references/`, `templates/`, and `scripts//`. The `@qoderai/better-harness` npm package includes the Qoder, Claude Code, -Codex, Cursor, and Qwen plugin metadata roots. The generated Qoder runtime -bundle includes only the Qoder shell, `.qoder-plugin/`; non-Qoder generated host -artifacts remain source-local. Claude Code installs its shell through the -repository's native marketplace manifest. +Codex, Cursor, Qwen, and GitHub Copilot plugin metadata roots. The generated +Qoder runtime bundle includes only the Qoder shell, `.qoder-plugin/`; non-Qoder +generated host artifacts remain source-local. Claude Code installs its shell +through the repository's native marketplace manifest. | Host | Positioning | Shell | Configured Assets | Session Evidence | Default Output | Rules / Prompts | Smoke | | --- | --- | --- | --- | --- | --- | --- | --- | @@ -23,6 +23,7 @@ repository's native marketplace manifest. | 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 | | 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 | ## Discovery And Evidence @@ -55,6 +56,22 @@ repository's native marketplace manifest. JSONL transcripts under `~/.qwen/projects//chats/`. The `qwen-extension.json` manifest is native Qwen install/discovery metadata included in the public npm package; it does not own Qwen evidence collection. +- GitHub Copilot configured assets are inventoried through + `scripts/agent-customize/providers/copilot.mjs`, covering `AGENTS.md`, + `.github/copilot-instructions.md`, `.github/instructions/`, `.github/skills/`, + `.agents/skills/`, `.github/agents/`, `.github/prompts/`, `.github/hooks/`, + `.mcp.json`, `.github/mcp.json`, and the user-scope `~/.copilot` equivalents. + Installed-Plugin records come from the `installedPlugins` array in + `~/.copilot/config.json` and stay separate from marketplace catalogs and + runtime-use claims. Session evidence comes from + `scripts/session-analysis/platforms/copilot.mjs`, which reads + workspace-matching `~/.copilot/session-state//events.jsonl` bound through + each session's `workspace.yaml`. Copilot transcripts record no per-response + model token usage, and a matched session directory without `events.jsonl` + stays an explicit partial coverage boundary. `~/.copilot/session-store.db` is + documented as automatically managed and is not an evidence source. The + `.github/plugin/` shell is native Copilot install/discovery metadata included + in the public npm package; it does not own Copilot evidence collection. ## Output Modes @@ -62,7 +79,7 @@ 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 visual output contract, covering +- `html-visual.md`: portable Claude Code/Codex/Cursor/Qwen/Copilot 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 11e461c..0062d13 100644 --- a/docs/adrs/directory-structure.md +++ b/docs/adrs/directory-structure.md @@ -42,6 +42,11 @@ Legend: .codex-plugin/ # [active] thin Codex shell plugin.json # thin discovery/install metadata only +.github/plugin/ # [active] thin GitHub Copilot shell + plugin.json marketplace.json # native install/discovery metadata only + +qwen-extension.json # [active] thin Qwen Code shell + .agents/ skills// # [active] host-local only; shared logic -> root skills/ SKILL.md @@ -71,7 +76,7 @@ scripts/ core-change-watch/ # [active] static structure/core-path/history evidence session-analysis.mjs # [active] thin shim; new exports -> scripts/session-analysis/ session-analysis/ # [active] session evidence collection/normalization - platforms/.mjs # Qoder/Codex/Claude/Cursor/Qwen host adapters + platforms/.mjs # Qoder/Codex/Claude/Cursor/Qwen/Copilot host adapters ides// # target editor-local evidence not covered by host adapters / # [target] new capability owner cli.mjs # use cli.mjs for new capabilities @@ -206,9 +211,10 @@ Use the tree first. These rules resolve common collisions: - Shared workflows go to root `skills/`; host-local wrappers or generated mirrors go to `.agents/skills/`. - Host plugin directories such as `.claude-plugin/`, `.qoder-plugin/`, - `.cursor-plugin/`, and `.codex-plugin/` are install/discovery shells for one - host. Existing active shells may be hand-maintained narrowly, but the Qoder - public npm package ships all five plugin metadata roots, while the Qoder + `.cursor-plugin/`, `.codex-plugin/`, and `.github/plugin/` are + install/discovery shells for one host. Existing active shells may be + hand-maintained narrowly, but the Qoder + public npm package ships all six plugin metadata roots, while the Qoder runtime bundle ships only `.qoder-plugin/`. New host shells start from the `docs/adapters/README.md` matrix; split to `docs/adapters/.md` and add a source-local `scripts/packaging/` builder only for an accepted host-artifact diff --git a/docs/community.md b/docs/community.md index c71051a..fd14a10 100644 --- a/docs/community.md +++ b/docs/community.md @@ -32,7 +32,7 @@ This is the complete reference. For the common cases, see Start Here above. | Style grammar | Yes | `templates/style/` | Directive-only visual language; no runnable skeletons | Selected by report/style routing | Style-template tests and no copied runtime skeletons | | Structured knowledge | Candidate only | `knowledge-base/{official,community}/...` | `knowledge.md`, interim `schema.json`, fixtures, namespace uniqueness | Docs-only until registry spec, compiler, and binding tests exist | Namespace check, schema/fixture review, migration note | | Examples and operating models | Yes | `case-studies/` | Named example, scope, evidence boundary, non-runtime status | Reference material only unless separately bound | Link/path check; no runtime-policy claims | -| Host shell and packaging | Thin, or generated only after a split trigger | `.claude-plugin/`, `.qoder-plugin/`, `.cursor-plugin/`, `.codex-plugin/`, `qwen-extension.json`, future lifecycle shells | Install/discovery metadata and pointers to canonical owners | Public npm package includes all five current metadata roots; the Qoder runtime bundle includes only `.qoder-plugin/`, and generated host artifacts stay source-local | `scripts/npm-package/` verification, or split adapter note plus target builder | +| Host shell and packaging | Thin, or generated only after a split trigger | `.claude-plugin/`, `.qoder-plugin/`, `.cursor-plugin/`, `.codex-plugin/`, `.github/plugin/`, `qwen-extension.json`, future lifecycle shells | Install/discovery metadata and pointers to canonical owners | Public npm package includes all six current metadata roots; the Qoder runtime bundle includes only `.qoder-plugin/`, and generated host artifacts stay source-local | `scripts/npm-package/` verification, or split adapter note plus target builder | ## Non-Extension Boundaries diff --git a/docs/concepts.md b/docs/concepts.md index f5eb3bf..7f7467a 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -73,7 +73,7 @@ until you need diagnosis. See [../models/routing.md](../models/routing.md). | Project evidence | `better-harness core-change-watch` | Project, history, core-path, and diff signals | | Change confidence | `hooks/git-scripts/blast-radius` | Symbol-graph blast radius of a change | | Dependency governance | `better-harness dependency-governance` | Update automation, audit, stale-dep signals | -| Session evidence | `better-harness session-analysis` | Normalize Qoder, Codex, Claude, Cursor, or Qwen session behavior | +| Session evidence | `better-harness session-analysis` | Normalize Qoder, Codex, Claude, Cursor, Qwen, or Copilot session behavior | | Agent assets | `better-harness coding-agent-practices inventory` | Inventory configured agent surfaces | | Guardrails | `hooks/`, `scripts/agent-guardrails` | Secret scanning and lifecycle checks | diff --git a/docs/glossary.md b/docs/glossary.md index 78bb8b6..0e885de 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -60,7 +60,7 @@ for extension surfaces, read [community.md](community.md). | `core-change-watch` | Project, history, core-path, and current-diff evidence collection. | [scripts/core-change-watch](../scripts/core-change-watch) | | Blast radius | The symbol-graph reach of a change, computed with tree-sitter (JS/TS, Go, Python) as a git hook. | [hooks/git-scripts/blast-radius](../hooks/git-scripts/blast-radius) | | `dependency-governance` | Update-automation, audit, and stale-dependency signals. | [scripts/dependency-governance](../scripts/dependency-governance) | -| `session-analysis` | Normalizes Qoder, Codex, Claude, Cursor, or Qwen agent session behavior into evidence. | [scripts/session-analysis](../scripts/session-analysis) | +| `session-analysis` | Normalizes Qoder, Codex, Claude, Cursor, Qwen, or Copilot agent session behavior into evidence. | [scripts/session-analysis](../scripts/session-analysis) | | Guardrails | Change-time enforcement: secret scanning and lifecycle hook checks. | [hooks](../hooks), [scripts/agent-guardrails](../scripts/agent-guardrails) | ## The Action Loop (Report → Change) @@ -79,7 +79,7 @@ for extension surfaces, read [community.md](community.md). |---|---|---| | Skill | A repeatable agent workflow defined by `SKILL.md` frontmatter plus a concise workflow. | [community.md](community.md); report use: [report contract](../skills/better-harness/SKILL.md#report-output) | | Host adapter | Per-host discovery and evidence-shape glue (e.g. Qoder, Codex); keeps the engine host-neutral. | [adapters/README.md](adapters/README.md) | -| Host shell | Thin host metadata (`.claude-plugin/`, `.qoder-plugin/`, `.cursor-plugin/`, `.codex-plugin/`, `qwen-extension.json`, or a future lifecycle shell) that exposes canonical behavior without owning product logic; the public npm package ships all five current metadata roots, while the Qoder runtime bundle includes only `.qoder-plugin/`. | [ARCHITECTURE.md](ARCHITECTURE.md) | +| Host shell | Thin host metadata (`.claude-plugin/`, `.qoder-plugin/`, `.cursor-plugin/`, `.codex-plugin/`, `.github/plugin/`, `qwen-extension.json`, or a future lifecycle shell) that exposes canonical behavior without owning product logic; the public npm package ships all six current metadata roots, while the Qoder runtime bundle includes only `.qoder-plugin/`. | [ARCHITECTURE.md](ARCHITECTURE.md) | | Canonical owner | The single directory that owns a behavior's product judgment; host shells and mirrors point back to it. | [ARCHITECTURE.md](ARCHITECTURE.md) | ## "I Want To… → Use" diff --git a/docs/specs/2026-07-29-copilot-host-support.md b/docs/specs/2026-07-29-copilot-host-support.md new file mode 100644 index 0000000..0c08359 --- /dev/null +++ b/docs/specs/2026-07-29-copilot-host-support.md @@ -0,0 +1,151 @@ +# Add GitHub Copilot as a supported host + +## Traceability + +- Spec ID: `copilot-host-support` +- Status: Implemented + +## Intent + +Make GitHub Copilot a first-class, evidence-safe Better Harness host alongside +Qoder, Codex, Claude Code, Cursor, and Qwen Code. + +Copilot already loads the canonical `better-harness` Skill today, but only by +falling back to the Claude Code shell: Copilot resolves plugin manifests in the +order `.plugin/plugin.json`, `plugin.json`, `.github/plugin/plugin.json`, +`.claude-plugin/plugin.json`, and marketplace manifests in the order +`marketplace.json`, `.plugin/marketplace.json`, `.github/plugin/marketplace.json`, +`.claude-plugin/marketplace.json`. That fallback is undocumented for users, +gives Copilot no shell of its own, and leaves the load order dependent on which +other host shells happen to exist. + +The larger gap is evidence. Every provider enum rejects `copilot`, so the +workflow cannot inventory Copilot's configured assets or read Copilot session +transcripts. A Copilot user can run the Skill but cannot get a Copilot-scoped +Harness report. + +Copilot's transcript is the richest of any supported host: `events.jsonl` +records hook, subagent, plan, compaction, and permission lifecycle events +directly. That evidence must be normalized without inventing coverage Copilot +does not record. Copilot records output tokens per assistant message but no +input tokens, cache tokens, or cost, and VS Code Copilot Chat has no documented +durable transcript, so both stay explicit boundaries rather than zero values. + +## Acceptance Scenarios + +- **CHS-AC-1 (native shell):** The repository exposes a Copilot-native plugin + manifest at `.github/plugin/plugin.json` and a marketplace manifest at + `.github/plugin/marketplace.json`. Both carry the `package.json` version, and + the manifest declares `skills` explicitly rather than relying on a default. + Copilot resolves them ahead of `.claude-plugin/`, so host selection no longer + depends on the Claude shell. +- **CHS-AC-2 (version alignment):** `package.json` and the Qoder, Codex, Claude + Code, Cursor, Qwen, and Copilot host manifests expose the same package + version, and the packaging verification gate covers the new manifests. +- **CHS-AC-3 (configured assets):** `agent-customize` supports + `--provider copilot` through a capability-owned provider module that + inventories Copilot Rules, Skills, Agents, Hooks, MCP, and installed-Plugin + metadata from documented Copilot locations. Installed-Plugin records stay + separate from marketplace catalogs and never become runtime-use claims. +- **CHS-AC-4 (session evidence):** `session-analysis` supports + `--platform copilot` through a capability-owned platform module that reads + workspace-matching `~/.copilot/session-state//events.jsonl`, and both the + public and capability-owned analyzer factories resolve it. +- **CHS-AC-5 (evidence boundaries):** Copilot facts carry the per-response + `outputTokens` the transcript records and omit input tokens, cache tokens, and + cost rather than reporting them as zero, so per-response usage coverage is + partial and complete usage still requires the opt-in OpenTelemetry export. + Permission request and result events normalize into the shared permission + lifecycle without retaining prompt intents, paths, or commands. Session + Diagnostics states that VS Code Copilot Chat has no supported durable + transcript and that `session-store.db` is documented as auto-managed and is not + an evidence source. +- **CHS-AC-6 (bundle propagation):** `harness evidence-bundle --platform copilot` + freezes a Copilot context and returns all three lanes, and `--copilot-home` + routes isolated configuration paths into the Agent Customize lane through both + the collector API and the `agent-customize` CLI. +- **CHS-AC-7 (host routing):** The host adapter matrix carries a Copilot row with + discovery paths, evidence sources, default output, and a smoke command. + Portable HTML routing includes Copilot, and Qoder remains the only Canvas host. +- **CHS-AC-8 (provider behavior):** Deterministic fixtures cover Copilot asset + inventory and transcript normalization, including tool call/result pairing, + hook lifecycle, subagent delegation, and absent or unreadable transcripts. Raw + prompts, commands, output, paths, and secrets do not enter production facts. +- **CHS-AC-9 (documentation integrity):** Markdown links and the generated + Better Harness documentation routing graph remain current, and the README + documents the marketplace install path rather than the deprecated direct + install. + +## Non-goals + +- Modify `~/.copilot`, publish to `github/copilot-plugins` or + `github/awesome-copilot`, or install a user-level Copilot Skill. +- Read `~/.copilot/session-store.db`. It is documented as automatically managed + and its schema is internal. +- Treat VS Code Copilot Chat `debug-logs` as durable session evidence. +- Add Copilot OpenTelemetry ingestion for token usage. +- Add a `scripts/packaging/` Copilot host-artifact builder. That path is scoped + to an accepted host-artifact contract and currently supports Codex only. +- Automate Copilot cloud agent `enabledPlugins` configuration. +- Add Copilot sources to `agent-lint` host instructions, which currently support + Qoder, Claude Code, and Codex only. +- Add Copilot metadata to the Qoder runtime bundle. +- Treat configured Copilot assets, zero candidates, or a loaded Skill as proof of + runtime quality or Skill invocation. + +## Plan and Tasks + +1. Add `.github/plugin/plugin.json` and `.github/plugin/marketplace.json` as a + thin Copilot shell, and include `.github/plugin/` in the public package files + list. (CHS-AC-1, CHS-AC-2) +2. Add `scripts/agent-customize/providers/copilot.mjs` as the capability-local + owner and register it in the provider registry. Resolve Copilot home through + `COPILOT_HOME` and an explicit override, and build the same Manage + collections as existing providers. (CHS-AC-3) +3. Add `scripts/session-analysis/platforms/copilot.mjs` and register it in both + the public `session-analysis.mjs` dispatch and the capability-owned + `analyzer.mjs` dispatch. (CHS-AC-4, CHS-AC-5) +4. Register `copilot` in the remaining provider enums: evidence-bundle contract + and lanes, report run, task-loop source and report, report quality, + coding-agent-practices asset baseline, asset integrity, and inventory, + lifecycle demand signals, session core facts, selection profile, usage + summary, and agent-lint usage. (CHS-AC-6) +5. Add `references/agent-customize/platforms/copilot.md`, a Copilot asset route, + and a Copilot section in Session Diagnostics. (CHS-AC-3, CHS-AC-5) +6. Add the Copilot host matrix row, update the metadata-root counts across the + architecture, community, glossary, and concepts docs, and route Copilot + through portable HTML reporting. (CHS-AC-7) +7. Document the Copilot marketplace install path in both READMEs. (CHS-AC-9) +8. Extend tests and packaging verification for the new shell, provider, + platform, and enums, replacing the assertion that excluded Copilot from the + host matrix. (CHS-AC-2, CHS-AC-8, CHS-AC-9) + +## Test and Review Evidence + +- `node --test` +- `node --test test/doc-link-graph.test.mjs` +- `npm run pack:verify` +- `copilot plugin marketplace add ` then + `copilot plugin install better-harness@better-harness`, expecting the reported + skill install count. +- `node scripts/better-harness.mjs harness evidence-bundle --platform copilot + --workspace . --depth quick --format json` + +## Risk + +Copilot's plugin surface is evolving: direct repository installs are deprecated +in favor of marketplace installs, and the manifest resolution order is the only +guarantee that a Copilot-native shell takes precedence over the Claude shell. +Both are pinned by the host matrix row and manifest tests so a Copilot change +surfaces as a failing contract rather than silent host misrouting. + +Copilot transcript event names are observed from a real session rather than a +published schema. The platform module therefore treats unknown event types as +ignorable and keeps coverage explicit instead of inferring activity. + +## Unknowns + +- [NEEDS CLARIFICATION: whether Copilot publishes a versioned JSON schema for + `plugin.json` that the manifest test should validate against.] +- [NEEDS CLARIFICATION: whether `~/.copilot/session-state` layout is stable + enough to promote from observed to documented in the host matrix.] diff --git a/package.json b/package.json index d0e7e1e..3646dd3 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ ".claude-plugin/", ".codex-plugin/", ".cursor-plugin/", + ".github/plugin/", ".qoder-plugin/", "qwen-extension.json", "AGENTS.md", diff --git a/references/agent-customize/README.md b/references/agent-customize/README.md index ac74fc2..92f31f4 100644 --- a/references/agent-customize/README.md +++ b/references/agent-customize/README.md @@ -14,8 +14,8 @@ authority, routing, overlap, observed use, and maintenance boundaries. `mcp-review.md`, `memory-review.md`, `hooks-review.md`, `custom-agents-review.md`, and `knowledge-assets-review.md`. - Inventory and authority: `global-assets.md`. -- Provider-specific notes: `platforms/claude.md`, `platforms/codex.md`, and - `platforms/qoder.md`. +- Provider-specific notes: `platforms/claude.md`, `platforms/codex.md`, + `platforms/qoder.md`, `platforms/qwen.md`, and `platforms/copilot.md`. ## Does Not Own diff --git a/references/agent-customize/global-assets.md b/references/agent-customize/global-assets.md index 98fd443..77c2c70 100644 --- a/references/agent-customize/global-assets.md +++ b/references/agent-customize/global-assets.md @@ -1,19 +1,21 @@ # Global Coding-Agent Assets Use this reference when a readiness run, screenshot, or user request points to -Cursor, Qoder, Codex, or Claude settings, installed assets, global skills, user hooks, -commands, agents, plugins, MCPs, or memories. Treat this as a configured asset -inventory, not a session behavior report. +Cursor, Qoder, Codex, Claude, Qwen, or Copilot settings, installed assets, +global skills, user hooks, commands, agents, plugins, MCPs, or memories. Treat +this as a configured asset inventory, not a session behavior report. ## Scope -- Project assets: `.cursor`, `.qoder`, `.codex`, `.claude`, `.agents`, project rules, - skills, agents, commands, hooks, workflows, settings, and MCP config. -- User/global assets: `~/.{cursor,qoder,codex,claude}` skills, hooks, commands, - agents, rules, settings, and MCP config. +- Project assets: `.cursor`, `.qoder`, `.codex`, `.claude`, `.agents`, + `.github`, project rules, skills, agents, commands, hooks, workflows, + settings, and MCP config. +- User/global assets: `~/.{cursor,qoder,codex,claude,qwen,copilot}` skills, + hooks, commands, agents, rules, settings, and MCP config. - Plugin/marketplace assets: provider plugin caches and install evidence under - `~/.cursor`, `~/.qoder`, `~/.codex`, and `~/.claude`, including plugin-declared Skills, - MCPs, Commands, Hooks, Rules, and Subagents. + `~/.cursor`, `~/.qoder`, `~/.codex`, `~/.claude`, `~/.qwen`, and + `~/.copilot`, including plugin-declared Skills, MCPs, Commands, Hooks, Rules, + and Subagents. - Memories: `~/.qoder/memories/**` plus Qoder `SharedClientCache` `app-config.json` memory keys and `cache/db/*.db*` file presence; and Codex generated-memory metadata under `~/.codex/memories/` plus supported @@ -26,14 +28,15 @@ inventory, not a session behavior report. Run the read-only inventory when user-home or installed assets are in scope: ```bash - /scripts/agent-customize/cli.mjs inventory --provider --workspace - /scripts/coding-agent-practices/inventory.mjs --workspace --include-user-home --include-memories --format markdown - coding-agent-practices asset-integrity --workspace --language --json [--include-memories] [--include-user-home] + /scripts/agent-customize/cli.mjs inventory --provider --workspace + /scripts/coding-agent-practices/inventory.mjs --workspace --include-user-home --include-memories --format markdown + coding-agent-practices asset-integrity --workspace --language --json [--include-memories] [--include-user-home] ``` Use `--cursor-home `, `--qoder-home `, `--codex-home `, -`--claude-home `, `--claude-state `, -`--codex-app-path `, or `--shared-cache ` for fixtures, alternate +`--claude-home `, `--qwen-home `, `--copilot-home `, +`--claude-state `, `--codex-app-path `, or `--shared-cache ` +for fixtures, alternate installs, or non-standard homes. Use the `agent-customize` command as the provider-specific configured asset source of truth; use the `coding-agent-practices` wrapper when the report also needs the matrix shape or diff --git a/references/agent-customize/platforms/copilot.md b/references/agent-customize/platforms/copilot.md new file mode 100644 index 0000000..971bfe9 --- /dev/null +++ b/references/agent-customize/platforms/copilot.md @@ -0,0 +1,104 @@ +# GitHub Copilot Best Practices + +Use this file for GitHub Copilot-specific operating practice. Use `../routing.md` +for host-neutral owner selection and `codex.md`/`claude.md`/`qwen.md` for other +host references. Do not copy Copilot-only workflow advice into shared docs +unless there is matching surface evidence. + +## Operating Frame + +Treat Copilot as a configured teammate across two surfaces: Copilot CLI and the +IDE agent. Both read the same repository instruction and Skill locations, so +durable guidance placed under `.github/` and `AGENTS.md` serves every surface. +Move repeated guidance into instructions, configure Copilot for the real +workflow, connect external systems through MCP, turn repeated work into Skills, +and automate only stable workflows through hooks. + +## Prompt Shape + +A strong first prompt has four parts: + +- **Goal**: the change, bug, review, artifact, or decision needed. +- **Context**: files, folders, docs, examples, logs, errors, or other material + Copilot should inspect. +- **Constraints**: architecture rules, safety limits, review standards, platform + requirements, and do-not-touch boundaries. +- **Done when**: tests, checks, behavior, output files, or review evidence that + prove the task is complete. + +## Durable Guidance + +Copilot combines every matching instruction file rather than choosing one, so +keep each file scoped and non-contradictory: + +- `AGENTS.md` for repo layout, commands, conventions, and what "done" means. +- `.github/copilot-instructions.md` for Copilot-specific repository guidance. +- `.github/instructions/*.instructions.md` for path-scoped guidance. A file with + no `applyTo` glob is never auto-applied. +- `~/.copilot/copilot-instructions.md` and `~/.copilot/instructions/` for + personal defaults that should not live in the repository. + +Keep context files short and practical. Put large or conditional detail in +linked references. When Copilot repeats a mistake, update durable guidance only +when the lesson is reusable. + +## Configuration + +Copilot settings cascade, with later layers overriding earlier ones: built-in +defaults, managed policy, `~/.copilot/settings.json`, +`.github/copilot/settings.json`, then environment variables and command flags. + +- Keep permission and approval settings tight until a trusted workflow needs + more access. +- Use `COPILOT_HOME` to isolate a Copilot home for testing. +- `~/.copilot/config.json` is automatically managed application state, not user + configuration. Read it for installed-plugin metadata only. + +## Skills, Agents, and Plugins + +Turn a repeated workflow into a Skill when it has stable triggers, inputs, +steps, outputs, and validation. Skill and Agent resolution is first-found-wins, +and plugin-provided assets are the lowest local tier, so a project or personal +asset always wins over a plugin asset of the same name. + +- Project Skills: `.github/skills/`, `.agents/skills/`, `.claude/skills/`. +- Personal Skills: `~/.copilot/skills/`, `~/.agents/skills/`. +- Custom Agents: `.github/agents/*.agent.md` and `~/.copilot/agents/`. +- Plugins resolve a manifest from `.plugin/plugin.json`, `plugin.json`, + `.github/plugin/plugin.json`, or `.claude-plugin/plugin.json`, in that order. + Marketplaces resolve `marketplace.json` from the equivalent roots. + +Prefer marketplace installs. Direct repository, URL, and local-path installs are +deprecated in favor of `plugin@marketplace` installs. + +## External Context + +Use MCP when Copilot needs context or actions outside the repository. MCP servers +are configured in `~/.copilot/mcp-config.json` (user) or `.mcp.json` and +`.github/mcp.json` (project). MCP resolution is last-wins, so a plugin server +overrides a user server of the same name. Start with one or two MCP tools that +remove a real repeated cost. + +## Automation + +Copilot hooks load from `.github/hooks/*.json`, `~/.copilot/hooks/`, inline +`hooks` in settings, and plugin hook files. Event names accept a camelCase form +(`sessionStart`, `preToolUse`, `agentStop`) and a PascalCase form +(`SessionStart`, `PreToolUse`, `Stop`) that matches the IDE format. Command +hooks accept `bash` and `powershell` variants, so keep automation +cross-platform. + +Automate only stable workflows, and prefer a prompt reminder when a blocking +hook would be disproportionate. + +## Evidence Boundaries + +- Configured Skills, Agents, hooks, MCP servers, and installed Plugins prove a + mechanism exists. They never prove it ran. +- Copilot CLI transcripts live at + `~/.copilot/session-state//events.jsonl` with a `workspace.yaml` + descriptor. See `../../session-evidence/sessions-diagnostics.md` for the + supported collection route. +- `~/.copilot/session-store.db` is documented as automatically managed. Never + read or decode it. +- Copilot transcripts record no per-response model token usage. diff --git a/references/agent-customize/routing.md b/references/agent-customize/routing.md index d47c419..f6a13cf 100644 --- a/references/agent-customize/routing.md +++ b/references/agent-customize/routing.md @@ -31,12 +31,13 @@ Route by ownership before choosing a vendor-specific feature: - Agent guides (`AGENTS.md`, `CLAUDE.md`, Copilot, Cursor, Qoder rules) -> `agents-md-review.md`. -- Cursor/Qoder/Codex/Claude project or user assets -> `global-assets.md`; for - Claude-specific configured-asset scope, then load `platforms/claude.md`; for - Codex-specific operating practice, then load `platforms/codex.md`; for - Qoder-specific feature taxonomy, then load `platforms/qoder.md`. For - installed, user-home, settings screenshot, plugin cache, or memory scope, run - the Global/User Asset Pass. +- Cursor/Qoder/Codex/Claude/Qwen/Copilot project or user assets -> + `global-assets.md`; for Claude-specific configured-asset scope, then load + `platforms/claude.md`; for Codex-specific operating practice, then load + `platforms/codex.md`; for Qoder-specific feature taxonomy, then load + `platforms/qoder.md`; for Copilot-specific operating practice, then load + `platforms/copilot.md`. For installed, user-home, settings screenshot, plugin + cache, or memory scope, run the Global/User Asset Pass. - Prior decision, user correction, remembered preference, stale recall, cross-window adoption, or memory-safety question -> `memory-review.md` after `global-assets.md` establishes the configured/storage boundary. Memory files @@ -336,3 +337,37 @@ Use the Global/User Asset Pass from `global-assets.md` when the user asks about Qwen global assets such as `~/.qwen/skills` or `~/.qwen/hooks`, installed extensions, or memories. Keep configured inventory evidence separate from observed session behavior. + +## Copilot Asset Route + +For GitHub Copilot-specific actions, use `platforms/copilot.md` as the operating +practice reference for prompt shape, instruction files, `.github` and +`~/.copilot` configuration, testing and review loops, MCP, Skills, Agents, +hooks, and plugins. Presence is not execution proof. + +Inspect configured surfaces before projecting readiness evidence: + +- `AGENTS.md`, `.github/copilot-instructions.md`, and + `.github/instructions/*.instructions.md` for durable repo context. Copilot + combines every matching instruction file instead of choosing one, and an + instruction file without an `applyTo` glob is never auto-applied. +- `~/.copilot/settings.json` and `.github/copilot/settings.json` for model, + approval, permission, MCP, and hook defaults. `~/.copilot/config.json` is + automatically managed state, not user configuration. +- `.github/skills`, `.agents/skills`, `~/.copilot/skills`, and `~/.agents/skills` + for repeatable workflows. Resolution is first-found-wins and plugin-provided + Skills are the lowest local tier. +- `.github/agents/*.agent.md` and `~/.copilot/agents/` for custom Agents. +- MCP configuration (`~/.copilot/mcp-config.json`, project `.mcp.json`, and + `.github/mcp.json`) and connector availability for external context. +- `.github/hooks/*.json` and `~/.copilot/hooks/` for lifecycle automation. +- Installed Plugins recorded in the `installedPlugins` array of + `~/.copilot/config.json`, with plugin roots under + `~/.copilot/installed-plugins///`. Keep installed records + separate from marketplace catalogs and from runtime-use claims. +- Session, diff, test, build, and review evidence for observed execution. + +Use the Global/User Asset Pass from `global-assets.md` when the user asks about +Copilot global assets such as `~/.copilot/skills` or `~/.copilot/hooks`, or +installed plugins. Keep configured inventory evidence separate from observed +session behavior. diff --git a/references/session-evidence/sessions-diagnostics.md b/references/session-evidence/sessions-diagnostics.md index 7b3a64a..6cff846 100644 --- a/references/session-evidence/sessions-diagnostics.md +++ b/references/session-evidence/sessions-diagnostics.md @@ -12,22 +12,22 @@ fails validation: ```bash # Discover evidence roots for a workspace - scripts/session-analysis.mjs sources --platform --workspace /path/to/repo + scripts/session-analysis.mjs sources --platform --workspace /path/to/repo # Session list with event counts and time range - scripts/session-analysis.mjs facets --platform --workspace /path/to/repo --limit 20 + scripts/session-analysis.mjs facets --platform --workspace /path/to/repo --limit 20 # Compact insight cards and action candidates - scripts/session-analysis.mjs insights --platform --workspace /path/to/repo --limit 20 + scripts/session-analysis.mjs insights --platform --workspace /path/to/repo --limit 20 # Read single session events - scripts/session-analysis.mjs show --platform --workspace /path/to/repo --session-id --include-events + scripts/session-analysis.mjs show --platform --workspace /path/to/repo --session-id --include-events # Diagnose the facts admission funnel and resolve candidate refs to local sessions - scripts/session-analysis.mjs facts --platform --workspace /path/to/repo --selection all-eligible --limit 5 --debug --output /tmp/session-facts-debug.json + scripts/session-analysis.mjs facts --platform --workspace /path/to/repo --selection all-eligible --limit 5 --debug --output /tmp/session-facts-debug.json # Expand one debug locator with normalized commands and user text - scripts/session-analysis.mjs show --platform --workspace /path/to/repo --session-id --include-events --include-command-text --include-user-text + scripts/session-analysis.mjs show --platform --workspace /path/to/repo --session-id --include-events --include-command-text --include-user-text ``` `facts --debug` is an operator-only diagnostic route. It exposes raw session @@ -38,7 +38,7 @@ opening only the candidate sessions needed to explain a surprising aggregate. Command and user text flags are also local-only and must not be used for broad transcript dumps. -Supported platforms: `qoder`, `codex`, `claude`, `cursor`, and `qwen`. Do not invent +Supported platforms: `qoder`, `codex`, `claude`, `cursor`, `qwen`, and `copilot`. Do not invent unsupported platform names. Always pass the absolute target workspace and load the matching Platform Notes before interpreting source roots or workspace bindings. @@ -138,7 +138,7 @@ in scope: - Generated reports: source evidence JSON, usage JSON, analysis packets, review packets, final reports, and quality-check output. - For installed or global assets, run - `scripts/agent-customize/cli.mjs inventory --provider ` + `scripts/agent-customize/cli.mjs inventory --provider ` or the `scripts/coding-agent-practices/inventory.mjs` wrapper, and treat its output as configured asset inventory, not session behavior. @@ -247,3 +247,42 @@ tool results as provider-labelled coverage. Generated files under Route configured Qwen rules (`QWEN.md`), Skills, hooks, extensions, and other project/user assets through `../agent-customize/global-assets.md`; configured presence does not prove use. + +### Copilot + +For GitHub Copilot CLI, the analyzer reads workspace-matching transcripts under +`~/.copilot/session-state//events.jsonl`. Each session directory +carries a `workspace.yaml` descriptor whose `cwd` binds the session to a +workspace; the analyzer also accepts the `context.cwd` recorded on the +`session.start` event. Copilot writes one typed lifecycle record per line, so +transcript records are the primary source: `user.message`, `assistant.message`, +`tool.execution_start`, `tool.execution_complete`, `hook.start`, `hook.end`, +`subagent.started`, `subagent.completed`, `session.plan_changed`, +`session.compaction_start`, `session.compaction_complete`, +`permission.requested`, `permission.completed`, +`session.permissions_changed`, and `external_tool.*`. Unrecognized types stay +explicit `metadata.*` events rather than being dropped or reinterpreted. + +Keep four Copilot boundaries explicit: + +- Copilot records `outputTokens` per assistant message and nothing else. Carry + that field as partial per-response usage; never fill input tokens, cache + tokens, or cost with zero. Subagent totals (`totalTokens`) and + `preCompactionTokens` are aggregates and never become per-response usage. + Complete usage evidence requires the opt-in OpenTelemetry export, which this + workflow does not read. +- Permission evidence comes from the `permission.requested` / + `permission.completed` pair, joined on `requestId`. Retain only the request + kind and the result decision. The prompt intent, paths, and commands are + payloads and are never retained. `session.permissions_changed` reports a mode + change, not a decision. +- A session directory can match the workspace and carry no `events.jsonl`. That + is partial coverage, not zero activity. +- `~/.copilot/session-store.db` is documented as automatically managed. Never + read or decode it. + +VS Code Copilot Chat has no supported durable transcript; its `debug-logs` +output is undocumented and stays `unobserved`. Route configured Copilot rules +(`AGENTS.md`, `.github/copilot-instructions.md`, `.github/instructions/`), +Skills, Agents, hooks, MCP, and installed Plugins through +`../agent-customize/global-assets.md`; configured presence does not prove use. diff --git a/scripts/agent-customize/cli.mjs b/scripts/agent-customize/cli.mjs index 63b9ada..baf9edc 100644 --- a/scripts/agent-customize/cli.mjs +++ b/scripts/agent-customize/cli.mjs @@ -5,12 +5,12 @@ import { collectAgentCustomizeInventory, filterManageItems, groupManageItems } f function usage() { return [ - "Usage: better-harness agent-customize [inventory|manage] --provider [--workspace ]", + "Usage: better-harness agent-customize [inventory|manage] --provider [--workspace ]", " better-harness agent-customize manage --provider [--tab ] [--query ] [--scope ] [--group-by ]", "", "Collect configured agent-customize inventory for one provider as JSON.", "Provider home overrides: --cursor-home, --qoder-home, --codex-home, --claude-home,", - "--qwen-home, --claude-state, --codex-app-path, --qoder-shared-client-cache-root.", + "--qwen-home, --copilot-home, --claude-state, --codex-app-path, --qoder-shared-client-cache-root.", "", ].join("\n"); } @@ -30,6 +30,7 @@ function summarize(inventory, options) { codexHome: inventory.codexHome, claudeHome: inventory.claudeHome, qwenHome: inventory.qwenHome, + copilotHome: inventory.copilotHome, claudeStatePath: inventory.claudeStatePath, codexAppPath: inventory.codexAppPath, sharedClientCacheRoot: inventory.sharedClientCacheRoot, @@ -72,6 +73,7 @@ async function main() { codexHome: options["codex-home"], claudeHome: options["claude-home"], qwenHome: options["qwen-home"], + copilotHome: options["copilot-home"], claudeStatePath: options["claude-state"] ?? options["claude-state-path"], codexAppPath: options["codex-app-path"], qoderSharedClientCacheRoot: options["qoder-shared-client-cache-root"] ?? options["shared-client-cache-root"], diff --git a/scripts/agent-customize/providers/copilot.mjs b/scripts/agent-customize/providers/copilot.mjs new file mode 100644 index 0000000..a04542e --- /dev/null +++ b/scripts/agent-customize/providers/copilot.mjs @@ -0,0 +1,425 @@ +import os from "node:os"; +import path from "node:path"; + +import { pathExists } from "../../session-analysis/fs.mjs"; +import { expandHome, normalizeWorkspace } from "../../session-analysis/paths.mjs"; +import { MANAGE_TABS } from "../constants.mjs"; +import { + agentsMarkdownRuleSource, + buildManageCollections, + collectHookItems, + collectMarkdownItems, + collectMcpFromConfig, + collectRuleSources, + collectSkillFiles, + designMarkdownRuleSource, + directoryRuleSource, + evidence, + normalizePluginDisplayName, + pluginMetadataEvidencePath, + readJson, + readText, + sortByName, + titleCase, + uniqueAssetsByRealPath, + workspaceSourceLabel, +} from "../core/items.mjs"; + +// Copilot resolves a plugin manifest from these roots, in this order. +const COPILOT_PLUGIN_MANIFESTS = [ + [".plugin", "plugin.json"], + ["plugin.json"], + [".github", "plugin", "plugin.json"], + [".claude-plugin", "plugin.json"], +]; + +function defaultCopilotHome() { + return process.env.COPILOT_HOME ?? path.join(os.homedir(), ".copilot"); +} + +// Copilot writes `config.json` and `settings.json` as JSONC with whole-line `//` +// comments. Only full-line comments are stripped so string values that contain +// `//` (for example URLs) stay intact. +async function readCopilotJson(filePath) { + const text = await readText(filePath, 512_000); + if (!text) return undefined; + const stripped = text + .split(/\r?\n/u) + .filter((line) => !/^\s*\/\//u.test(line)) + .join("\n"); + try { + return JSON.parse(stripped); + } catch { + return undefined; + } +} + +function copilotInstructionsRuleSource(filePath, scope, sourceLabel, rootForEvidence, precedence) { + return { + type: "file", + filePath, + scope, + sourceLabel, + rootForEvidence, + name: path.basename(filePath), + sourceKind: "copilot-instructions", + precedence, + useHeading: true, + }; +} + +function normalizeSettingsMcp(name, config, scope, sourceLabel, evidencePath, rootForEvidence) { + return { + name, + scope, + sourceLabel, + command: config.command ?? null, + args: config.args ?? [], + url: config.url ?? config.httpUrl ?? null, + evidence: evidence(evidencePath, rootForEvidence), + }; +} + +function flattenSettingsHooks(settingsHooks) { + if (!settingsHooks || typeof settingsHooks !== "object" || Array.isArray(settingsHooks)) return []; + const items = []; + for (const [, definitions] of Object.entries(settingsHooks)) { + if (!Array.isArray(definitions)) continue; + for (const definition of definitions) { + const hooks = Array.isArray(definition?.hooks) ? definition.hooks : [definition]; + for (const hook of hooks) { + const command = hook?.command ?? hook?.bash ?? hook?.powershell; + if (command) items.push({ command, type: hook?.type ?? "command" }); + else if (hook?.url) items.push({ command: hook.url, type: "http" }); + } + } + } + return items; +} + +function normalizeProvidedCopilotRecord(record) { + const name = String(record?.name ?? "").trim(); + const installPath = record?.cache_path ?? record?.cachePath ?? record?.installPath; + if (!name || !installPath) { + return undefined; + } + const marketplaceName = record.marketplace || "_direct"; + return { + id: `${marketplaceName}/${name}`, + name, + marketplaceName, + installPath: path.resolve(expandHome(installPath)), + version: record.version, + enabled: record.enabled !== false, + source: record.source?.source ?? (record.marketplace ? "marketplace" : "direct"), + sources: [record.marketplace ? "marketplace" : "direct"], + installMatch: record.marketplace ? "copilot-marketplace" : "copilot-direct", + installedAt: record.installed_at ?? record.installedAt, + }; +} + +async function readCopilotInstalledPluginState(options = {}) { + if (Array.isArray(options.copilotInstalledPluginRecords) || Array.isArray(options.installedPluginRecords)) { + const records = (options.copilotInstalledPluginRecords ?? options.installedPluginRecords) + .map(normalizeProvidedCopilotRecord) + .filter(Boolean); + return { records, source: "provided", installRecordFiles: [] }; + } + + const copilotHome = path.resolve(expandHome(options.copilotHome ?? options["copilot-home"] ?? defaultCopilotHome())); + const configPath = path.join(copilotHome, "config.json"); + const config = (await readCopilotJson(configPath)) ?? {}; + const entries = Array.isArray(config.installedPlugins) ? config.installedPlugins : []; + const records = entries.map(normalizeProvidedCopilotRecord).filter(Boolean); + return { + records, + source: records.length > 0 ? "copilot-config" : "missing", + installRecordFiles: records.length > 0 ? [configPath] : [], + }; +} + +async function collectCopilotPluginMcpItems(pluginRoot, sourceLabel, manifest) { + const items = []; + if (manifest?.mcpServers && typeof manifest.mcpServers === "object") { + for (const [name, config] of Object.entries(manifest.mcpServers)) { + if (!config || typeof config !== "object") continue; + items.push({ + name, + scope: "plugin", + sourceLabel, + command: config.command ?? null, + args: config.args ?? [], + evidence: evidence(path.join(pluginRoot, "plugin.json"), pluginRoot), + }); + } + } + for (const candidate of [ + path.join(pluginRoot, ".mcp.json"), + path.join(pluginRoot, ".github", "mcp.json"), + ]) { + if (await pathExists(candidate)) { + items.push(...(await collectMcpFromConfig(candidate, "plugin", sourceLabel, pluginRoot))); + } + } + return items; +} + +async function readCopilotPluginManifest(pluginRoot) { + for (const segments of COPILOT_PLUGIN_MANIFESTS) { + const candidate = path.join(pluginRoot, ...segments); + if (await pathExists(candidate)) { + return (await readJson(candidate)) ?? {}; + } + } + return {}; +} + +function manifestPaths(manifest, key, fallback) { + const value = manifest?.[key]; + if (typeof value === "string") return [value]; + if (Array.isArray(value)) return value.filter((entry) => typeof entry === "string"); + return fallback; +} + +async function collectCopilotPlugin(record) { + const pluginRoot = path.resolve(expandHome(record.installPath)); + if (!(await pathExists(pluginRoot))) { + return null; + } + const metadataEvidencePath = await pluginMetadataEvidencePath(pluginRoot, COPILOT_PLUGIN_MANIFESTS); + const manifest = await readCopilotPluginManifest(pluginRoot); + const readme = await readText(path.join(pluginRoot, "README.md"), 6000); + const heading = readme.match(/^#\s+(.+)$/mu)?.[1]?.trim(); + const rawDisplayName = manifest.displayName || heading || titleCase(manifest.name || record.name); + const displayName = normalizePluginDisplayName(rawDisplayName, record.name); + const plugin = { + id: record.id, + copilotPluginName: record.name, + marketplaceName: record.marketplaceName, + rootPath: pluginRoot, + scope: "plugin", + sourceLabel: displayName, + name: manifest.name || record.name, + displayName, + description: manifest.description || "", + publisher: { displayName: titleCase(record.marketplaceName) }, + version: record.version || manifest.version, + installSources: record.sources, + installSource: record.source, + installMatch: record.installMatch, + installedAt: record.installedAt, + enabled: record.enabled, + evidence: evidence(metadataEvidencePath, path.dirname(path.dirname(pluginRoot))), + }; + + const skillRoots = manifestPaths(manifest, "skills", ["skills"]); + const agentRoots = manifestPaths(manifest, "agents", ["agents"]); + const commandRoots = manifestPaths(manifest, "commands", ["commands"]); + + plugin.skills = ( + await Promise.all( + skillRoots.map((root) => collectSkillFiles(path.resolve(pluginRoot, root), "plugin", displayName, pluginRoot)), + ) + ) + .flat() + .sort(sortByName); + plugin.subagents = ( + await Promise.all( + agentRoots.map((root) => + collectMarkdownItems(path.resolve(pluginRoot, root), "subagent", "plugin", displayName, pluginRoot), + ), + ) + ) + .flat() + .sort(sortByName); + plugin.commands = ( + await Promise.all( + commandRoots.map((root) => + collectMarkdownItems(path.resolve(pluginRoot, root), "command", "plugin", displayName, pluginRoot), + ), + ) + ) + .flat() + .sort(sortByName); + plugin.mcpServers = await collectCopilotPluginMcpItems(pluginRoot, displayName, manifest); + plugin.rules = []; + plugin.hooks = await collectHookItems(pluginRoot, "plugin", displayName, pluginRoot); + return plugin; +} + +async function collectCopilotPlugins(records) { + const plugins = []; + for (const record of records) { + const plugin = await collectCopilotPlugin(record); + if (plugin) { + plugins.push(plugin); + } + } + const byId = new Map(); + for (const plugin of plugins.sort(sortByName)) { + if (!byId.has(plugin.id)) { + byId.set(plugin.id, plugin); + } + } + return [...byId.values()].sort(sortByName); +} + +async function collectCopilotUserPrimitives(copilotHome) { + const settingsPath = path.join(copilotHome, "settings.json"); + const settings = (await readCopilotJson(settingsPath)) ?? {}; + const mcps = []; + const mcpConfigPath = path.join(copilotHome, "mcp-config.json"); + if (await pathExists(mcpConfigPath)) { + mcps.push(...((await collectMcpFromConfig(mcpConfigPath, "user", "User", copilotHome)) ?? [])); + } + if (settings.mcpServers && typeof settings.mcpServers === "object") { + for (const [name, config] of Object.entries(settings.mcpServers)) { + if (!config || typeof config !== "object") continue; + if (!mcps.some((mcp) => mcp.name === name)) { + mcps.push(normalizeSettingsMcp(name, config, "user", "User", settingsPath, copilotHome)); + } + } + } + + const hooks = await collectHookItems(copilotHome, "user", "User", copilotHome); + for (const hook of flattenSettingsHooks(settings.hooks)) { + if (!hooks.some((existing) => existing.command === hook.command)) { + hooks.push({ + command: hook.command, + scope: "user", + sourceLabel: "User", + evidence: evidence(settingsPath, copilotHome), + }); + } + } + + const skills = await uniqueAssetsByRealPath([ + ...(await collectSkillFiles(path.join(copilotHome, "skills"), "user", "User", copilotHome)), + ...(await collectSkillFiles( + path.join(path.dirname(copilotHome), ".agents", "skills"), + "user", + "User", + copilotHome, + )), + ]); + + return { + skills: skills.sort(sortByName), + subagents: await collectMarkdownItems(path.join(copilotHome, "agents"), "subagent", "user", "User", copilotHome), + rules: await collectRuleSources([ + copilotInstructionsRuleSource( + path.join(copilotHome, "copilot-instructions.md"), + "user", + "User", + copilotHome, + "before-project-rules", + ), + directoryRuleSource(path.join(copilotHome, "instructions"), "user", "User", copilotHome, { + sourceKind: "copilot-instructions", + precedence: "before-project-rules", + }), + ]), + commands: [], + hooks, + mcps, + }; +} + +async function collectCopilotWorkspacePrimitives(workspace) { + const sourceLabel = await workspaceSourceLabel(workspace); + const github = path.join(workspace, ".github"); + + const skills = await uniqueAssetsByRealPath([ + ...(await collectSkillFiles(path.join(github, "skills"), "project", sourceLabel, workspace)), + ...(await collectSkillFiles(path.join(workspace, ".agents", "skills"), "project", sourceLabel, workspace)), + ]); + + const mcps = []; + for (const candidate of [path.join(workspace, ".mcp.json"), path.join(github, "mcp.json")]) { + if (await pathExists(candidate)) { + for (const mcp of (await collectMcpFromConfig(candidate, "project", sourceLabel, workspace)) ?? []) { + if (!mcps.some((existing) => existing.name === mcp.name)) mcps.push(mcp); + } + } + } + + const settingsPath = path.join(github, "copilot", "settings.json"); + const settings = (await readCopilotJson(settingsPath)) ?? {}; + const hooks = await collectHookItems(github, "project", sourceLabel, workspace); + for (const hook of flattenSettingsHooks(settings.hooks)) { + if (!hooks.some((existing) => existing.command === hook.command)) { + hooks.push({ + command: hook.command, + scope: "project", + sourceLabel, + evidence: evidence(settingsPath, workspace), + }); + } + } + + return { + skills: skills.sort(sortByName), + subagents: await collectMarkdownItems(path.join(github, "agents"), "subagent", "project", sourceLabel, workspace), + rules: await collectRuleSources([ + copilotInstructionsRuleSource( + path.join(github, "copilot-instructions.md"), + "project", + sourceLabel, + workspace, + "before-agents-md", + ), + directoryRuleSource(path.join(github, "instructions"), "project", sourceLabel, workspace, { + sourceKind: "copilot-instructions", + precedence: "before-agents-md", + }), + agentsMarkdownRuleSource(workspace, sourceLabel), + designMarkdownRuleSource(workspace, sourceLabel), + ]), + commands: await collectMarkdownItems(path.join(github, "prompts"), "command", "project", sourceLabel, workspace), + hooks, + mcps, + }; +} + +function emptyPrimitives() { + return { skills: [], subagents: [], rules: [], commands: [], hooks: [], mcps: [] }; +} + +export async function collectCopilotCustomizeInventory(options = {}) { + const copilotHome = path.resolve( + expandHome(options.copilotHome ?? options["copilot-home"] ?? defaultCopilotHome()), + ); + const workspace = normalizeWorkspace(options.workspace ?? process.cwd()); + const includeUserHome = options.includeUserHome !== false; + const installState = includeUserHome + ? await readCopilotInstalledPluginState({ ...options, copilotHome }) + : { records: [], source: "not-authorized", installRecordFiles: [] }; + const [plugins, user, project] = await Promise.all([ + includeUserHome ? collectCopilotPlugins(installState.records ?? []) : [], + includeUserHome ? collectCopilotUserPrimitives(copilotHome) : emptyPrimitives(), + collectCopilotWorkspacePrimitives(workspace), + ]); + return { + generatedAt: new Date().toISOString(), + provider: "copilot", + copilotHome, + workspace, + tabs: MANAGE_TABS, + plugins, + manage: buildManageCollections(plugins, user, project), + diagnostics: { + installedPluginState: installState.source, + installedPluginRecordCount: plugins.length, + installedPluginRecordFiles: installState.installRecordFiles ?? [], + remotePluginInstallMarkersRequired: false, + }, + unsupported: [ + "remote marketplace browse ordering", + "remote marketplace display metadata", + "organization and enterprise managed plugin standards", + "remote organization and enterprise custom agents", + "session-scoped hooks", + "cloud MCP authentication state", + ], + }; +} diff --git a/scripts/agent-customize/providers/index.mjs b/scripts/agent-customize/providers/index.mjs index 768444c..a000baa 100644 --- a/scripts/agent-customize/providers/index.mjs +++ b/scripts/agent-customize/providers/index.mjs @@ -1,5 +1,6 @@ import { collectClaudeCustomizeInventory } from "./claude.mjs"; import { collectCodexCustomizeInventory } from "./codex.mjs"; +import { collectCopilotCustomizeInventory } from "./copilot.mjs"; import { collectCursorCustomizeInventory } from "./cursor.mjs"; import { collectQoderCustomizeInventory } from "./qoder.mjs"; import { collectQwenCustomizeInventory } from "./qwen.mjs"; @@ -10,6 +11,7 @@ export const PROVIDER_COLLECTORS = new Map([ ["codex", collectCodexCustomizeInventory], ["claude", collectClaudeCustomizeInventory], ["qwen", collectQwenCustomizeInventory], + ["copilot", collectCopilotCustomizeInventory], ]); export async function collectProviderInventory(provider, options = {}) { diff --git a/scripts/agent-lint/cli.mjs b/scripts/agent-lint/cli.mjs index 4f728b2..a66279b 100644 --- a/scripts/agent-lint/cli.mjs +++ b/scripts/agent-lint/cli.mjs @@ -167,7 +167,7 @@ function usage() { return [ "Usage: better-harness agent-lint [--workspace ] [--profile agents-md-review|agent-assets-review] [--json|--format markdown]", " better-harness agent-lint --workspace-root --scan-children --profile agents-md-review", - " better-harness agent-lint --profile agent-assets-review --provider [--skill ]", + " better-harness agent-lint --profile agent-assets-review --provider [--skill ]", "", "Parse agent instruction entrypoints and bounded local Markdown references into review evidence.", "", diff --git a/scripts/better-harness-cli/registry.mjs b/scripts/better-harness-cli/registry.mjs index 7aba164..71c892e 100644 --- a/scripts/better-harness-cli/registry.mjs +++ b/scripts/better-harness-cli/registry.mjs @@ -52,7 +52,7 @@ const COMMANDS = [ kind: "direct", audience: "advanced", script: "session-analysis.mjs", - summary: "Collect and normalize Qoder, Codex, Claude, Cursor, and Qwen session evidence.", + summary: "Collect and normalize Qoder, Codex, Claude, Cursor, Qwen, and Copilot session evidence.", subcommands: [ { name: "sources", diff --git a/scripts/coding-agent-practices/asset-baseline.mjs b/scripts/coding-agent-practices/asset-baseline.mjs index fb117cf..9c1c433 100644 --- a/scripts/coding-agent-practices/asset-baseline.mjs +++ b/scripts/coding-agent-practices/asset-baseline.mjs @@ -15,7 +15,7 @@ export const ASSET_BASELINE_SCHEMA_VERSION = 1; export const MAX_BASELINE_FINDINGS = 16; export const MAX_BASELINE_OWNER_ROUTES = 16; -const PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen"]); +const PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen", "copilot"]); const SEVERITY_RANK = Object.freeze({ error: 0, warning: 1, advisory: 2 }); const OWNER_KIND_RANK = Object.freeze({ rules: 0, @@ -200,7 +200,7 @@ function available(data) { export async function collectAssetBaseline(options = {}, dependencies = {}) { const provider = options.provider ?? options.platform ?? "qoder"; if (!PROVIDERS.has(provider)) { - throw new Error(`Unsupported provider: ${provider}. Supported providers: qoder, codex, claude, cursor, qwen.`); + throw new Error(`Unsupported provider: ${provider}. Supported providers: qoder, codex, claude, cursor, qwen, copilot.`); } const workspace = normalizeWorkspace(options.workspace ?? "."); const includeUserHome = parseBooleanFlag(options.includeUserHome ?? options["include-user-home"] ?? false); @@ -300,7 +300,7 @@ export function formatAssetBaselineMarkdown(result) { return `${lines.join("\n")}\n`; } -const USAGE = `Usage: better-harness coding-agent-practices asset-baseline [qoder|codex|claude|cursor|qwen] [options] +const USAGE = `Usage: better-harness coding-agent-practices asset-baseline [qoder|codex|claude|cursor|qwen|copilot] [options] Collect one compact, read-only AI evidence envelope from a shared asset snapshot. diff --git a/scripts/coding-agent-practices/asset-integrity.mjs b/scripts/coding-agent-practices/asset-integrity.mjs index 8105f94..a7585d4 100644 --- a/scripts/coding-agent-practices/asset-integrity.mjs +++ b/scripts/coding-agent-practices/asset-integrity.mjs @@ -340,7 +340,7 @@ export function formatAssetIntegrityMarkdown(result) { return `${lines.join("\n")}\n`; } -const USAGE = `Usage: better-harness coding-agent-practices asset-integrity [qoder|codex|claude|cursor|qwen] [options] +const USAGE = `Usage: better-harness coding-agent-practices asset-integrity [qoder|codex|claude|cursor|qwen|copilot] [options] Run a read-only metadata integrity review for Memory titles, enabled Plugins, and Hooks. Memory bodies are never read. @@ -368,8 +368,8 @@ async function runCli(argv) { } const { command, options } = parseArgs(argv); const provider = options.provider ?? options.platform ?? command ?? "qoder"; - if (!["qoder", "codex", "claude", "cursor", "qwen"].includes(provider)) { - throw new Error(`Unsupported provider: ${provider}. Supported providers: qoder, codex, claude, cursor, qwen.`); + if (!["qoder", "codex", "claude", "cursor", "qwen", "copilot"].includes(provider)) { + throw new Error(`Unsupported provider: ${provider}. Supported providers: qoder, codex, claude, cursor, qwen, copilot.`); } const includeUserHome = options.includeUserHome ?? options["include-user-home"] ?? false; const includeMemories = options.includeMemories ?? options["include-memories"] ?? false; diff --git a/scripts/coding-agent-practices/inventory.mjs b/scripts/coding-agent-practices/inventory.mjs index 353ba1e..31ff229 100644 --- a/scripts/coding-agent-practices/inventory.mjs +++ b/scripts/coding-agent-practices/inventory.mjs @@ -379,7 +379,7 @@ async function collectCodexMemories(scope) { } function makeSessionSourceHints(scope) { - if (!["qoder", "codex", "claude", "cursor", "qwen"].includes(scope.platform)) { + if (!["qoder", "codex", "claude", "cursor", "qwen", "copilot"].includes(scope.platform)) { return []; } return [ @@ -448,6 +448,7 @@ function providerScope(options = {}, platform = options.platform ?? "qoder") { claudeHome: options.claudeHome ?? options["claude-home"], claudeStatePath: options.claudeStatePath ?? options["claude-state"] ?? options["claude-state-path"], qwenHome: options.qwenHome ?? options["qwen-home"], + copilotHome: options.copilotHome ?? options["copilot-home"], }; } @@ -536,7 +537,7 @@ function customizeSurface({ provider, group, scope, type, label, basePath, items async function buildConfiguredAssetSurfaces(inventory, scope) { const provider = scope.platform; const projectBase = scope.workspace; - const userBase = inventory.cursorHome ?? inventory.qoderHome ?? inventory.codexHome ?? inventory.claudeHome ?? inventory.qwenHome; + const userBase = inventory.cursorHome ?? inventory.qoderHome ?? inventory.codexHome ?? inventory.claudeHome ?? inventory.qwenHome ?? inventory.copilotHome; const surfaceTypes = [ ["skills", "skills", "Skills"], ["subagents", "agents", "Agents"], @@ -639,6 +640,7 @@ export async function collectProviderInventory(options = {}) { claudeHome: scope.claudeHome, claudeStatePath: scope.claudeStatePath, qwenHome: scope.qwenHome, + copilotHome: scope.copilotHome, includeUserHome: scope.includeUserHome, includeGlobalHooks: scope.includeGlobalHooks, }); @@ -890,12 +892,12 @@ export function formatInventoryMarkdown(result) { return `${lines.join("\n")}\n`; } -const USAGE = `Usage: better-harness coding-agent-practices inventory [qoder|codex|claude|cursor|qwen] [options] +const USAGE = `Usage: better-harness coding-agent-practices inventory [qoder|codex|claude|cursor|qwen|copilot] [options] Inspect configured coding-agent assets and practice evidence for one platform. Options: - --platform Select the platform (default: qoder; may also be the first positional) + --platform Select the platform (default: qoder; may also be the first positional) --workspace Workspace root to inspect (default: current directory) --json Emit JSON (default) --format Output format @@ -917,9 +919,9 @@ async function runCli(argv) { } const { command, options } = parseArgs(argv); const platform = options.platform ?? command ?? "qoder"; - if (!["cursor", "qoder", "codex", "claude", "qwen"].includes(platform)) { + if (!["cursor", "qoder", "codex", "claude", "qwen", "copilot"].includes(platform)) { throw new Error( - `Unsupported platform: ${platform}. Supported platforms: cursor, qoder, codex, claude, qwen.\n\n${USAGE}`, + `Unsupported platform: ${platform}. Supported platforms: cursor, qoder, codex, claude, qwen, copilot.\n\n${USAGE}`, ); } const result = platform === "qoder" diff --git a/scripts/harness-analysis/evidence-bundle/agent-customize.mjs b/scripts/harness-analysis/evidence-bundle/agent-customize.mjs index 737d91d..6eb0eb4 100644 --- a/scripts/harness-analysis/evidence-bundle/agent-customize.mjs +++ b/scripts/harness-analysis/evidence-bundle/agent-customize.mjs @@ -1,7 +1,7 @@ import { collectAssetBaseline } from "../../coding-agent-practices/asset-baseline.mjs"; import { availableLane, unavailableLane } from "./contract.mjs"; -const ASSET_PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen"]); +const ASSET_PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen", "copilot"]); export async function collectAgentCustomize(context, options = {}, dependencies = {}) { if (!ASSET_PROVIDERS.has(context.provider)) { diff --git a/scripts/harness-analysis/evidence-bundle/cli.mjs b/scripts/harness-analysis/evidence-bundle/cli.mjs index f176cf9..8b24f51 100644 --- a/scripts/harness-analysis/evidence-bundle/cli.mjs +++ b/scripts/harness-analysis/evidence-bundle/cli.mjs @@ -13,7 +13,7 @@ Harness, and Agent Customize specialists plus the lead analyzer. Options: --workspace Target workspace (required) - --platform qoder, codex, claude, cursor, or qwen (default: qoder) + --platform qoder, codex, claude, cursor, qwen, or copilot (default: qoder) --language Evidence language (default: en) --depth 7-day/3-item or 30-day/5-item review (default: normal) --since Override the frozen window start @@ -33,7 +33,7 @@ const ALLOWED = new Set([ "workspace", "platform", "provider", "language", "depth", "since", "until", "evidence-limit", "include-user-home", "include-memories", "canvas-out", "replace-canvas", "format", "json", "qoder-home", "codex-home", "claude-home", - "cursor-home", "qwen-home", "claude-state", "help", "h", + "cursor-home", "qwen-home", "copilot-home", "claude-state", "help", "h", ]); function assertOptions(command, options) { diff --git a/scripts/harness-analysis/evidence-bundle/contract.mjs b/scripts/harness-analysis/evidence-bundle/contract.mjs index 1b6438f..f395a25 100644 --- a/scripts/harness-analysis/evidence-bundle/contract.mjs +++ b/scripts/harness-analysis/evidence-bundle/contract.mjs @@ -8,7 +8,7 @@ export const EVIDENCE_LANE_NAMES = Object.freeze([ "agentCustomize", ]); -const PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen"]); +const PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen", "copilot"]); const DEPTHS = new Set(["quick", "normal"]); function enabled(value) { diff --git a/scripts/harness-analysis/report-quality.mjs b/scripts/harness-analysis/report-quality.mjs index 8758bef..27625e2 100644 --- a/scripts/harness-analysis/report-quality.mjs +++ b/scripts/harness-analysis/report-quality.mjs @@ -132,7 +132,7 @@ const AI_PRACTICE_SCOPE_NEGATIVE_RE = /^\s*(?:(?:not in scope|out of scope|not i const AI_PRACTICE_SECTION_RE = /^(#{2,4})\s+(?:AI Agent Practices|Coding Agent Practices|AI Agent 实践|智能体实践|编码代理实践)(?:\s|$)/im; const AI_PRACTICE_LABEL_RE = /^\s*\*\*(?:AI Agent Practices|Coding Agent Practices|AI Agent 实践|智能体实践|编码代理实践)\s*[::]?\*\*\s*$/im; const AI_PRACTICE_SURFACE_RE = /\b(?:Rules|Hooks|Skills|Custom Agents|MCP|Plugins|Session Insights|Sessions|DESIGN\.md|Design Tokens?|Design Contract|design-token contract)\b|规则|技能|自定义\s*(?:Agent|智能体)|插件|会话洞察|会话|设计(?:令牌|契约)/i; -const AI_PRACTICE_SESSION_SCOPE_RE = /(?:\.qoder|\.codex|\.claude|\.cursor|\.qwen|qoder|codex|claude|cursor|qwen|session-analysis|session sources|session evidence|会话分析|会话证据)/i; +const AI_PRACTICE_SESSION_SCOPE_RE = /(?:\.qoder|\.codex|\.claude|\.cursor|\.qwen|\.copilot|qoder|codex|claude|cursor|qwen|copilot|session-analysis|session sources|session evidence|会话分析|会话证据)/i; const AI_READINESS_DIMENSION_RE = /\bAI Readiness\b|\bAI Agent Readiness\b|AI\s*(?:就绪度|就绪|准备度)/i; const SESSION_SOURCES_RE = /session-analysis\.mjs\s+sources/i; const SESSION_BOUNDARY_RE = /session-analysis\.mjs\s+(?:sources\s+and\s+)?facets|no enabled roots|no enabled session|no sessions|no session(?: analysis)? evidence|no agent session logs|no execution history|no proof of agent workflow|no-session boundary|source probe failed|session-analysis (?:was )?not run|session sources.*none|qoder\/codex session sources.*none|没有启用.*(?:root|session|根|会话)|没有.*会话|源探测失败/i; diff --git a/scripts/harness-analysis/report-run.mjs b/scripts/harness-analysis/report-run.mjs index 81b5a5c..004ec57 100644 --- a/scripts/harness-analysis/report-run.mjs +++ b/scripts/harness-analysis/report-run.mjs @@ -19,7 +19,7 @@ an explicit Qoder Canvas output is requested. Options: --workspace Target workspace (required) - --platform qoder, codex, claude, cursor, or qwen (default: qoder) + --platform qoder, codex, claude, cursor, qwen, or copilot (default: qoder) --language en or zh-CN (default: en) --since Include sessions at or after the frozen window start --until Include sessions at or before the frozen window end @@ -36,7 +36,7 @@ function clone(value) { function reportPlatform(value = "qoder") { const platform = String(value || "qoder").toLowerCase(); - if (!["qoder", "codex", "claude", "cursor", "qwen"].includes(platform)) { + if (!["qoder", "codex", "claude", "cursor", "qwen", "copilot"].includes(platform)) { throw Object.assign(new Error(`unsupported Harness report platform: ${platform}`), { code: "UNSUPPORTED_REPORT_PLATFORM", }); @@ -51,7 +51,7 @@ function flagEnabled(value) { function assertCliOptions(options) { const allowed = new Set([ "workspace", "platform", "language", "since", "until", "format", "canvas-out", "replace-canvas", "include-global-capabilities", - "qoder-home", "codex-home", "claude-home", "cursor-home", "qwen-home", + "qoder-home", "codex-home", "claude-home", "cursor-home", "qwen-home", "copilot-home", ]); const positional = Array.isArray(options._) ? options._ : []; const unknown = Object.keys(options).filter((key) => key !== "_" && !allowed.has(key)); diff --git a/scripts/harness-analysis/task-loop-report.mjs b/scripts/harness-analysis/task-loop-report.mjs index fcfebc5..f7a2b63 100644 --- a/scripts/harness-analysis/task-loop-report.mjs +++ b/scripts/harness-analysis/task-loop-report.mjs @@ -1509,7 +1509,7 @@ function checkupReportFindings(source) { function usageOutcomeReviewLead(source, locale) { const usage = source?.sessionEvents?.usageEfficiency; if (!isObject(usage) || !isObject(usage.selection) || !isObject(usage.longSessions) || !isObject(usage.outcomeReview)) return null; - const platform = ["qoder", "codex", "claude", "cursor", "qwen"].includes(source?.manifest?.scope?.platform) + const platform = ["qoder", "codex", "claude", "cursor", "qwen", "copilot"].includes(source?.manifest?.scope?.platform) ? source.manifest.scope.platform : "qoder"; const activeCount = Number(usage?.longSessions?.activeCount ?? 0); diff --git a/scripts/harness-analysis/task-loop-source.mjs b/scripts/harness-analysis/task-loop-source.mjs index 3a14975..20c935a 100644 --- a/scripts/harness-analysis/task-loop-source.mjs +++ b/scripts/harness-analysis/task-loop-source.mjs @@ -66,13 +66,13 @@ const REQUIRED_SOFTWARE_FLUENCY_CAPABILITIES = Object.freeze([ const HELP = `Usage: node scripts/harness-analysis/task-loop-source.mjs --workspace --source [options] Create a conservative Agent Work Loop report-source candidate from normalized -Qoder, Codex, Claude, Cursor, or Qwen sessions. It retains privacy-safe episode, change, validation, +Qoder, Codex, Claude, Cursor, Qwen, or Copilot sessions. It retains privacy-safe episode, change, validation, repair-candidate, and explicit host-decision identities. Task understanding, validation relevance, repair, delivery, recovery, and Learning Capture remain unobserved until the prepared source-bound review resolves them. Options: - --platform + --platform Session platform (default: qoder) --workspace Target workspace (required) --source Candidate report.source.json path (required) @@ -813,7 +813,7 @@ export function buildTaskLoopSourceCandidate({ export async function collectAgentLintPracticeEvidence(options = {}) { const provider = options.platform ?? "qoder"; - const assetReviewSupported = ["qoder", "codex", "claude", "cursor", "qwen"].includes(provider); + const assetReviewSupported = ["qoder", "codex", "claude", "cursor", "qwen", "copilot"].includes(provider); const common = { workspace: options.workspace, provider, @@ -822,6 +822,7 @@ export async function collectAgentLintPracticeEvidence(options = {}) { claudeHome: options.claudeHome ?? options["claude-home"], cursorHome: options.cursorHome ?? options["cursor-home"], qwenHome: options.qwenHome ?? options["qwen-home"], + copilotHome: options.copilotHome ?? options["copilot-home"], }; const [instructionReview, assetReview, practiceInventory] = await Promise.all([ runAgentLint({ ...common, profile: "agents-md-review" }), diff --git a/scripts/npm-package/verify-pack.mjs b/scripts/npm-package/verify-pack.mjs index c5229ef..a5b3335 100644 --- a/scripts/npm-package/verify-pack.mjs +++ b/scripts/npm-package/verify-pack.mjs @@ -53,6 +53,8 @@ function verifyReleaseVersionAlignment() { [".claude-plugin/marketplace.json", readJson(".claude-plugin/marketplace.json").plugins?.[0]?.version], [".codex-plugin/plugin.json", readJson(".codex-plugin/plugin.json").version], [".cursor-plugin/plugin.json", readJson(".cursor-plugin/plugin.json").version], + [".github/plugin/plugin.json", readJson(".github/plugin/plugin.json").version], + [".github/plugin/marketplace.json", readJson(".github/plugin/marketplace.json").plugins?.[0]?.version], ["qwen-extension.json", readJson("qwen-extension.json").version], ]; for (const [source, version] of versions) { @@ -137,6 +139,8 @@ const required = [ "package/.codex-plugin/plugin.json", "package/.cursor-plugin/plugin.json", "package/.cursor-plugin/marketplace.json", + "package/.github/plugin/plugin.json", + "package/.github/plugin/marketplace.json", "package/.qoder-plugin/plugin.json", "package/qwen-extension.json", "package/case-studies/factory/model/factory-readiness.md", @@ -317,6 +321,7 @@ const forbiddenBundlePrefixes = [ ".claude-plugin/", ".codex-plugin/", ".cursor-plugin/", + ".github/plugin/", "qwen-extension.json", "test/", "dev/", diff --git a/scripts/session-analysis.mjs b/scripts/session-analysis.mjs index 51adbbc..bd08a58 100644 --- a/scripts/session-analysis.mjs +++ b/scripts/session-analysis.mjs @@ -231,7 +231,14 @@ async function loadPlatform(platform = "qoder") { main: module.main, }; } - throw new Error(`Unsupported platform: ${platform}. Supported platforms: qoder, codex, claude, cursor, qwen.`); + if (platform === "copilot") { + const module = await import("./session-analysis/platforms/copilot.mjs"); + return { + Analyzer: module.CopilotSessionAnalyzer, + main: module.main, + }; + } + throw new Error(`Unsupported platform: ${platform}. Supported platforms: qoder, codex, claude, cursor, qwen, copilot.`); } export async function createAnalyzer(platform = "qoder") { @@ -281,7 +288,7 @@ export async function main(argv = process.argv.slice(2)) { ] : []; process.stdout.write([ - `Usage: session-analysis${command ? ` ${command}` : " "} --platform --workspace [options]`, + `Usage: session-analysis${command ? ` ${command}` : " "} --platform --workspace [options]`, "", "Commands: sources, sessions, facets, insights, facts, file-reads, show, events, claude-facets", ...factsOptions, diff --git a/scripts/session-analysis/analyzer.mjs b/scripts/session-analysis/analyzer.mjs index 79c626f..5732f05 100644 --- a/scripts/session-analysis/analyzer.mjs +++ b/scripts/session-analysis/analyzer.mjs @@ -19,8 +19,8 @@ import { createCodexCliJsonModelClient } from "./codex-json-model.mjs"; export const SESSION_ANALYSIS_HELP = `Usage: better-harness session-analysis [command] [options] -Inspect local Qoder, Codex, Claude, Cursor, or Qwen session evidence. The default -command is sessions and the default platform is qoder. Help exits before +Inspect local Qoder, Codex, Claude, Cursor, Qwen, or Copilot session evidence. The +default command is sessions and the default platform is qoder. Help exits before reading HOME or workspace. Commands: @@ -35,13 +35,15 @@ Commands: events Show normalized events selected with --session-id Options: - --platform + --platform Session host (default: qoder) --workspace Workspace scope (default: current directory) --qoder-home Qoder data root (default: ~/.qoder) --codex-home Codex data root (default: ~/.codex) --claude-home Claude Code data root (default: ~/.claude) --cursor-home Cursor data root (default: ~/.cursor) + --qwen-home Qwen Code data root (default: ~/.qwen) + --copilot-home Copilot CLI data root (default: ~/.copilot) --include-cache Include optional Qoder cache evidence --include-global-capabilities Include optional user-global Qoder evidence @@ -264,7 +266,14 @@ async function loadPlatform(platform = "qoder") { main: module.main, }; } - throw new Error(`Unsupported platform: ${platform}. Supported platforms: qoder, codex, claude, cursor, qwen.`); + if (platform === "copilot") { + const module = await import("./platforms/copilot.mjs"); + return { + Analyzer: module.CopilotSessionAnalyzer, + main: module.main, + }; + } + throw new Error(`Unsupported platform: ${platform}. Supported platforms: qoder, codex, claude, cursor, qwen, copilot.`); } export async function createAnalyzer(platform = "qoder") { @@ -278,7 +287,7 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) { if (command === "claude-facets") { if (options.help === true) { stdout.write([ - "Usage: session-analysis claude-facets --platform --workspace [options]", + "Usage: session-analysis claude-facets --platform --workspace [options]", "", "Options:", " --limit <1-5> Maximum semantic facets (default: 5)", diff --git a/scripts/session-analysis/lifecycle-demand-signals.mjs b/scripts/session-analysis/lifecycle-demand-signals.mjs index f627aec..63500bf 100644 --- a/scripts/session-analysis/lifecycle-demand-signals.mjs +++ b/scripts/session-analysis/lifecycle-demand-signals.mjs @@ -540,7 +540,7 @@ function fingerprint(value) { function safeHost(value) { const host = String(value ?? "").toLowerCase(); - return ["qoder", "codex", "claude", "cursor", "qwen"].includes(host) ? host : "unknown"; + return ["qoder", "codex", "claude", "cursor", "qwen", "copilot"].includes(host) ? host : "unknown"; } function safeEvidenceToken(value, fallback) { diff --git a/scripts/session-analysis/platforms/copilot.mjs b/scripts/session-analysis/platforms/copilot.mjs new file mode 100644 index 0000000..b9a528e --- /dev/null +++ b/scripts/session-analysis/platforms/copilot.mjs @@ -0,0 +1,684 @@ +#!/usr/bin/env node + +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { SessionAnalyzer } from "../../session-analysis.mjs"; +import { parseArgs, parseBooleanFlag } from "../cli.mjs"; +import { forEachJsonLine, isDirectory, pathExists } from "../fs.mjs"; +import { expandHome, normalizeWorkspace } from "../paths.mjs"; +import { + emitProviderResult, + runProviderAnalysis, + runProviderCommand, +} from "../provider-runner.mjs"; +import { parseResultFacts } from "../result-facts.mjs"; +import { mergeTimeRange, normalizeCliDate, normalizeTimestamp, timestampMillis, withinTimeRange } from "../time.mjs"; + +const TRANSCRIPT_FILE = "events.jsonl"; +const WORKSPACE_FILE = "workspace.yaml"; + +function isWorkspaceMatch(candidate, workspace) { + if (!candidate) return false; + const resolved = normalizeWorkspace(candidate); + return resolved === workspace || resolved.startsWith(`${workspace}${path.sep}`); +} + +/** + * Read the `cwd` binding from a Copilot session's `workspace.yaml`. + * + * The file is a flat `key: value` list, so only the two keys this adapter needs + * are parsed. No YAML dependency is introduced and unknown keys are ignored. + */ +export function parseWorkspaceDescriptor(text) { + const descriptor = { id: null, cwd: null }; + for (const line of String(text ?? "").split(/\r?\n/u)) { + const match = /^(id|cwd):\s*(.+?)\s*$/u.exec(line); + if (!match) continue; + const value = match[2].replace(/^["']|["']$/gu, ""); + descriptor[match[1]] = value.length > 0 ? value : null; + } + return descriptor; +} + +function inferTimestamp(raw) { + return normalizeTimestamp(raw?.timestamp ?? null); +} + +function toolArguments(raw) { + const args = raw?.data?.arguments; + return args && typeof args === "object" && !Array.isArray(args) ? args : {}; +} + +function inferFilePath(toolName, input = {}) { + if (!/(?:read|edit|write|file|notebook|view|create|patch)/iu.test(String(toolName ?? ""))) return null; + return input.file_path ?? input.filePath ?? input.path ?? null; +} + +function inferCommandText(toolName, input = {}) { + if (!/(?:bash|shell|exec|terminal|run|powershell)/iu.test(String(toolName ?? ""))) return null; + return input.command ?? input.cmd ?? null; +} + +function evidenceRef(sourceRef, type) { + return { + kind: sourceRef.kind, + path: sourceRef.path, + line: sourceRef.line ?? null, + seq: null, + type, + }; +} + +function resultText(data) { + const result = data?.result; + if (typeof result === "string") return result; + return result?.content ?? result?.detailedContent ?? ""; +} + +// Copilot permission records carry protocol enums (`read`, `write`, `shell`, +// `approved`, `denied-interactively-by-user`, ...). The guard keeps unexpected +// values out of normalized events so prompt text can never leak through a field +// that is only meant to hold a bounded token. +const PERMISSION_TOKEN_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/u; + +function permissionToken(value) { + const token = String(value ?? "").trim().toLowerCase(); + return PERMISSION_TOKEN_PATTERN.test(token) ? token : null; +} + +function permissionDecisionFor(resultKind) { + const token = permissionToken(resultKind); + if (!token) return null; + if (token.startsWith("approved") || token.startsWith("allowed")) return "allowed"; + if (token.startsWith("denied") || token.startsWith("rejected")) return "denied"; + return token; +} + +/** + * Normalize one Copilot `events.jsonl` record. + * + * Copilot records a typed lifecycle stream. Event names are taken from observed + * transcripts rather than a published schema, so unrecognized types stay + * explicit `metadata.*` events instead of being dropped or reinterpreted. + */ +function transcriptEvents(raw, sourceRef, options) { + const rawType = String(raw?.type ?? "record"); + const data = raw?.data ?? {}; + const sessionId = data.sessionId ?? sourceRef.sessionId ?? null; + const base = { + sessionId, + timestamp: inferTimestamp(raw), + sourceKind: sourceRef.kind, + planningScope: "workspace", + cwd: data?.context?.cwd ?? sourceRef.cwd ?? null, + isSubagent: raw?.agentId ? true : null, + }; + const events = []; + + if (rawType === "user.message") { + const text = typeof data.content === "string" ? data.content : ""; + events.push({ + ...base, + type: "user", + category: "user", + evidenceRef: evidenceRef(sourceRef, "user"), + summary: text ? `user message (${text.length} chars)` : "user", + contentLength: text.length, + userPrompt: text.length > 0, + ...(options.includeUserText && text ? { userText: text } : {}), + ...(options.includeContent && text ? { content: text } : {}), + }); + return events; + } + + if (rawType === "assistant.message") { + const text = typeof data.content === "string" ? data.content : ""; + const outputTokens = Number(data.outputTokens); + const usageObserved = Number.isFinite(outputTokens) && outputTokens >= 0; + const event = { + ...base, + type: "assistant", + category: "assistant", + evidenceRef: evidenceRef(sourceRef, "assistant"), + summary: text ? `assistant message (${text.length} chars)` : "assistant", + contentLength: text.length, + }; + if (text) event.userVisibleAssistantMessage = true; + if (options.includeContent && text) event.content = text; + // When usage is observed the model is attributed on the companion response + // event instead, so a single response is not counted against two events. + if (data.model && !usageObserved) event.model = data.model; + events.push(event); + if (usageObserved) { + // Copilot reports output tokens per assistant message but never input + // tokens or cost, and `isModelRequestEvent` deliberately ignores plain + // `assistant` events. Emit a companion response event carrying only the + // fields that were actually observed -- omitted token fields read as 0 in + // `session-efficiency`, so no input usage or cost is invented here. + events.push({ + ...base, + type: "model.response.completed", + category: "model", + model: data.model ?? null, + modelUsage: { outputTokens }, + usageFieldsObserved: true, + responseId: data.messageId ?? null, + requestId: data.requestId ?? null, + evidenceRef: evidenceRef(sourceRef, "model.response.completed"), + summary: "assistant response usage (output tokens only)", + }); + } + return events; + } + + if (rawType === "tool.execution_start") { + const input = toolArguments(raw); + const toolName = data.toolName ?? "unknown-tool"; + const event = { + ...base, + type: "tool.call", + category: "tool", + lifecyclePhase: "request", + toolName, + toolInvocationId: data.toolCallId ?? null, + evidenceRef: evidenceRef(sourceRef, "tool.call"), + summary: `${toolName} request`, + }; + const commandText = inferCommandText(toolName, input); + const filePath = inferFilePath(toolName, input); + if (options.includeCommandText && commandText) event.commandText = commandText; + if (filePath) event.filePath = filePath; + if (/^(?:skill|task)$/iu.test(String(toolName))) { + const skillName = input.skill ?? input.name ?? input.agent_type; + if (skillName) { + event.skillName = String(skillName).split(":").at(-1); + event.skillNames = [event.skillName]; + } + } + events.push(event); + return events; + } + + if (rawType === "tool.execution_complete") { + const success = data.success !== false; + const event = { + ...base, + type: "tool.result", + category: "tool", + lifecyclePhase: "result", + toolInvocationId: data.toolCallId ?? null, + success, + hasError: !success, + evidenceRef: evidenceRef(sourceRef, "tool.result"), + summary: success ? "tool result" : "tool result failed", + }; + const facts = parseResultFacts(String(resultText(data)).slice(-8_192)); + if (facts) event.resultFacts = facts; + events.push(event); + return events; + } + + if (rawType === "hook.start" || rawType === "hook.end") { + const event = { + ...base, + type: rawType === "hook.start" ? "hook.call" : "hook.result", + category: "hook", + lifecyclePhase: rawType === "hook.start" ? "request" : "result", + hookEvent: data.hookType ?? null, + toolInvocationId: data.hookInvocationId ?? null, + evidenceRef: evidenceRef(sourceRef, rawType), + summary: `${data.hookType ?? "hook"} ${rawType === "hook.start" ? "request" : "result"}`, + }; + if (rawType === "hook.end") { + const success = data.success !== false; + event.success = success; + event.hasError = !success; + } + events.push(event); + return events; + } + + if (rawType === "subagent.started" || rawType === "subagent.completed") { + const event = { + ...base, + type: rawType === "subagent.started" ? "subagent.start" : "subagent.stop", + category: "delegation", + lifecyclePhase: rawType === "subagent.started" ? "request" : "result", + toolInvocationId: data.toolCallId ?? null, + subagentName: data.agentName ?? null, + evidenceRef: evidenceRef(sourceRef, rawType), + summary: `${data.agentName ?? "subagent"} ${rawType === "subagent.started" ? "started" : "completed"}`, + }; + if (data.model) event.model = data.model; + if (rawType === "subagent.completed") { + // Copilot reports subagent aggregates only. These are not per-response + // model usage and must not be projected as such. + if (Number.isFinite(Number(data.totalToolCalls))) event.subagentToolCalls = Number(data.totalToolCalls); + if (Number.isFinite(Number(data.totalTokens))) event.subagentTotalTokens = Number(data.totalTokens); + if (Number.isFinite(Number(data.durationMs))) event.subagentDurationMs = Number(data.durationMs); + } + events.push(event); + return events; + } + + if (rawType === "session.plan_changed") { + events.push({ + ...base, + type: "plan.update", + category: "planning", + evidenceRef: evidenceRef(sourceRef, "plan.update"), + summary: "session plan changed", + }); + return events; + } + + if (rawType === "session.compaction_start" || rawType === "session.compaction_complete") { + const event = { + ...base, + type: "context.compaction", + category: "context", + lifecyclePhase: rawType === "session.compaction_start" ? "request" : "result", + evidenceRef: evidenceRef(sourceRef, rawType), + summary: rawType === "session.compaction_start" ? "context compaction started" : "context compaction completed", + }; + if (Number.isFinite(Number(data.preCompactionTokens))) { + event.preCompactionTokens = Number(data.preCompactionTokens); + } + events.push(event); + return events; + } + + if (rawType === "permission.requested" || rawType === "permission.completed") { + // Copilot records permission handling as a request/result pair correlated by + // `requestId`. Only the correlation id and bounded protocol enums are kept -- + // the prompt payload (`intention`, `path`, `paths`, `commands`) is dropped. + // + // The decision rides the result event alone. Attaching one to the request as + // well would double-count every permission in the episode summaries, and + // Copilot emits a request even when policy auto-approves, so treating the + // request as a prompt would overstate friction. + const requested = rawType === "permission.requested"; + const event = { + ...base, + type: "control.permission", + category: "control", + lifecyclePhase: requested ? "request" : "result", + evidenceRef: evidenceRef(sourceRef, rawType), + summary: requested ? "permission requested" : "permission resolved", + }; + // Deliberately not `toolInvocationId`: a tool call can be re-prompted, and + // `dedupeEvents` keys on that field, which would drop real observations. + if (typeof data.requestId === "string" && data.requestId) { + event.permissionRequestId = data.requestId; + } + const kind = permissionToken(data.permissionRequest?.kind); + if (kind) event.permissionKind = kind; + if (!requested) { + const decision = permissionDecisionFor(data.result?.kind); + if (decision) event.permissionDecision = decision; + } + events.push(event); + return events; + } + + if (rawType === "session.permissions_changed" || rawType === "session.mode_changed") { + events.push({ + ...base, + type: "control.change", + category: "control", + evidenceRef: evidenceRef(sourceRef, rawType), + summary: rawType === "session.permissions_changed" ? "session permissions changed" : "session mode changed", + }); + return events; + } + + if (rawType === "external_tool.requested" || rawType === "external_tool.completed") { + events.push({ + ...base, + type: rawType === "external_tool.requested" ? "tool.call" : "tool.result", + category: "tool", + lifecyclePhase: rawType === "external_tool.requested" ? "request" : "result", + toolName: data.toolName ?? "external-tool", + toolInvocationId: data.toolCallId ?? null, + external: true, + evidenceRef: evidenceRef(sourceRef, rawType), + summary: `external tool ${rawType === "external_tool.requested" ? "request" : "result"}`, + }); + return events; + } + + if (rawType === "session.start") { + events.push({ + ...base, + type: "metadata.session-start", + category: "metadata", + model: data.selectedModel ?? null, + evidenceRef: evidenceRef(sourceRef, "metadata.session-start"), + summary: "Copilot session started", + }); + return events; + } + + events.push({ + ...base, + type: `metadata.${rawType}`, + category: "metadata", + evidenceRef: evidenceRef(sourceRef, `metadata.${rawType}`), + summary: rawType, + }); + return events; +} + +async function probeSessionDirectory(sessionDir, workspace) { + const transcriptPath = path.join(sessionDir, TRANSCRIPT_FILE); + const descriptor = parseWorkspaceDescriptor(await readWorkspaceDescriptor(sessionDir)); + const summary = { + sessionId: descriptor.id || path.basename(sessionDir), + transcriptPath, + transcriptAvailable: await pathExists(transcriptPath), + cwd: descriptor.cwd, + records: 0, + conversationRecords: 0, + requestRecords: 0, + firstSeen: null, + lastSeen: null, + workspaceMatch: isWorkspaceMatch(descriptor.cwd, workspace), + }; + if (!summary.transcriptAvailable) { + return summary; + } + + await forEachJsonLine(transcriptPath, (raw) => { + summary.records += 1; + if (raw?.type === "user.message" || raw?.type === "assistant.message") { + summary.conversationRecords += 1; + } + if (raw?.type === "assistant.message" && Number.isFinite(Number(raw?.data?.outputTokens))) { + summary.requestRecords += 1; + } + if (raw?.type === "session.start") { + const data = raw?.data ?? {}; + if (data.sessionId) summary.sessionId = data.sessionId; + const cwd = data?.context?.cwd; + if (cwd) { + summary.cwd = summary.cwd ?? cwd; + if (isWorkspaceMatch(cwd, workspace)) summary.workspaceMatch = true; + } + } + mergeTimeRange(summary, inferTimestamp(raw)); + }); + return summary; +} + +/** + * Classify Copilot workspace coverage. + * + * A Copilot session directory can exist and match the workspace while carrying + * no `events.jsonl`. That state stays explicit instead of collapsing into zero + * activity or a clean result. + * + * The payload populates the canonical `session-core-facts` transcript fields so + * public facts never report unmapped evidence as a confirmed zero. Copilot has + * no terminal source, so `terminalOnly` is a measured zero rather than an + * unknown, and sessions with a missing or empty transcript surface as + * `unreadable`. Copilot-specific counters are kept alongside for warnings and + * are dropped by the bounded public schema. + */ +function buildCopilotSourceCoverage({ scope, roots, matched, inWindow, inWindowProbes = [] }) { + const root = roots.find((entry) => entry.kind === "copilot-session-jsonl"); + const workspaceSessions = matched.length; + const withTranscript = matched.filter((probe) => probe.transcriptAvailable); + const withoutTranscript = workspaceSessions - withTranscript.length; + const timeUnobservedProbes = matched.filter((probe) => !probe.firstSeen && !probe.lastSeen); + const timeUnobserved = withTranscript.filter((probe) => !probe.firstSeen && !probe.lastSeen).length; + const emptyTranscripts = withTranscript.filter((probe) => probe.records === 0).length; + const requestedWindow = scope.sinceTime !== null || scope.untilTime !== null; + + // Mirrors the Cursor precedent. Without a requested window every matched + // session stays relevant, so a session whose transcript is missing or empty + // surfaces as `unreadable` instead of disappearing from the denominator. + let relevant = matched; + if (requestedWindow) { + relevant = inWindowProbes.length > 0 ? inWindowProbes : timeUnobservedProbes; + } + const withConversation = relevant.filter((probe) => probe.conversationRecords > 0).length; + const withRequest = relevant.filter((probe) => probe.requestRecords > 0).length; + const unreadable = relevant.filter((probe) => !probe.transcriptAvailable || probe.records === 0).length; + + let status = "observed"; + if (!root?.exists || workspaceSessions === 0) { + status = "absent"; + } else if (inWindow.length === 0 && requestedWindow && timeUnobserved === 0 && withTranscript.length > 0) { + status = "out-of-window"; + } else if (withTranscript.length === 0 || inWindow.length === 0) { + status = "unobserved"; + } else if (withoutTranscript > 0 || timeUnobserved > 0 || emptyTranscripts > 0) { + status = "partial"; + } + + return { + status, + transcript: { + sourceAvailable: Boolean(root?.exists), + workspaceSessions, + inWindowSessions: inWindow.length, + outOfWindowSessions: Math.max(workspaceSessions - inWindow.length - timeUnobservedProbes.length, 0), + timeUnobservedSessions: timeUnobserved, + relevantSessions: relevant.length, + withConversation, + withRequest, + // Copilot exposes no terminal-only source, so this is measured, not unknown. + terminalOnly: 0, + unreadable, + // Copilot-specific detail retained for adapter warnings. + withTranscript: withTranscript.length, + withoutTranscript, + emptyTranscripts, + }, + usage: { + // Copilot records output tokens per assistant message but never input + // tokens or cost, so per-response usage is partial. Subagent and + // compaction totals are aggregates and are never projected as per-response + // usage. + perResponseUsageObserved: withRequest > 0, + perResponseUsageFields: ["outputTokens"], + }, + }; +} + +async function readWorkspaceDescriptor(sessionDir) { + const descriptorPath = path.join(sessionDir, WORKSPACE_FILE); + try { + return await readFile(descriptorPath, "utf8"); + } catch { + return ""; + } +} + +function dedupeEvents(events) { + const seen = new Set(); + return events.filter((event) => { + const key = event.toolInvocationId && event.lifecyclePhase + ? `${event.sessionId}:${event.type}:${event.lifecyclePhase}:${event.toolInvocationId}` + : null; + if (!key) return true; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export class CopilotSessionAnalyzer extends SessionAnalyzer { + currentSessionId() { + return process.env.COPILOT_SESSION_ID ?? null; + } + + async resolveScope(options = {}) { + const since = normalizeCliDate(options.since, false); + const until = normalizeCliDate(options.until, true); + const workspace = normalizeWorkspace(options.workspace); + const home = path.resolve(expandHome( + options.home ?? options.copilotHome ?? options["copilot-home"] ?? process.env.COPILOT_HOME ?? "~/.copilot", + )); + return { + platform: "copilot", + workspace, + home, + since: since.label, + sinceTime: since.time, + until: until.label, + untilTime: until.time, + sessionId: options["session-id"] ?? options.sessionId ?? options._?.[0] ?? null, + includeGlobalCapabilities: parseBooleanFlag(options["include-global-capabilities"] ?? false), + }; + } + + async discoverSourceRoots(scope) { + const sessionStateRoot = path.join(scope.home, "session-state"); + return [ + { + id: "copilot-session-state", + kind: "copilot-session-jsonl", + role: "session-transcript", + path: sessionStateRoot, + optional: false, + enabled: true, + workspaceScoped: false, + coverage: "primary", + exists: await pathExists(sessionStateRoot), + }, + ]; + } + + async discoverSessions(scope, roots) { + const transcriptRoot = roots.find((root) => root.kind === "copilot-session-jsonl"); + let entries = []; + if (transcriptRoot?.exists) { + try { + entries = await readdir(transcriptRoot.path, { withFileTypes: true }); + } catch { + entries = []; + } + } + + const matched = []; + for (const entry of entries) { + const sessionDir = path.join(transcriptRoot.path, entry.name); + if (!entry.isDirectory() && !(await isDirectory(sessionDir))) continue; + const probe = await probeSessionDirectory(sessionDir, scope.workspace); + if (probe?.workspaceMatch) matched.push(probe); + } + + const inWindowProbes = matched + .filter((probe) => probe.transcriptAvailable) + .filter((probe) => { + const timestamp = probe.lastSeen ?? probe.firstSeen; + if ((scope.sinceTime !== null || scope.untilTime !== null) && !timestamp) return false; + return withinTimeRange(timestamp, scope); + }); + + const inWindow = inWindowProbes + .map((probe) => ({ + sessionId: probe.sessionId, + workspace: scope.workspace, + firstSeen: probe.firstSeen, + lastSeen: probe.lastSeen, + sourceKinds: [transcriptRoot.kind], + sourceRefs: [ + { + kind: transcriptRoot.kind, + role: transcriptRoot.role, + path: probe.transcriptPath, + cwd: probe.cwd, + firstSeen: probe.firstSeen, + lastSeen: probe.lastSeen, + }, + ], + })) + .sort((left, right) => (timestampMillis(right.lastSeen) ?? 0) - (timestampMillis(left.lastSeen) ?? 0)); + + scope._copilotSourceCoverage = buildCopilotSourceCoverage({ scope, roots, matched, inWindow, inWindowProbes }); + return inWindow; + } + + normalizeEvent(raw, sourceRef, options = {}) { + return this.normalizeEvents(raw, sourceRef, options)[0] ?? null; + } + + normalizeEvents(raw, sourceRef, options = {}) { + return transcriptEvents(raw, sourceRef, options); + } + + async readSession(session, scope, options = {}) { + const events = []; + for (const ref of session.sourceRefs ?? []) { + if (!ref.path.endsWith(".jsonl")) continue; + await forEachJsonLine(ref.path, (raw, line) => { + for (const event of this.normalizeEvents( + raw, + { ...ref, sessionId: session.sessionId, line }, + options, + )) { + if (withinTimeRange(event.timestamp, scope)) events.push(event); + } + }); + } + return dedupeEvents(events).sort((left, right) => + (timestampMillis(left.timestamp) ?? 0) - (timestampMillis(right.timestamp) ?? 0) + || Number(left.evidenceRef?.line ?? 0) - Number(right.evidenceRef?.line ?? 0)); + } + + async analysisWarnings(scope, _roots, _sessions) { + const coverage = scope._copilotSourceCoverage; + const warnings = [{ + code: "copilot-per-response-usage-partial", + message: "Copilot transcripts record output tokens per assistant response but no input tokens, cache tokens, or cost; complete usage evidence requires the opt-in OpenTelemetry export.", + }]; + if (!coverage || coverage.status === "absent") { + warnings.push({ + code: "copilot-workspace-transcripts-absent", + message: "No Copilot session transcript matched the selected workspace.", + }); + return warnings; + } + if (coverage.transcript.withoutTranscript > 0) { + warnings.push({ + code: "copilot-session-transcript-partial", + message: `Copilot session state matched ${coverage.transcript.workspaceSessions} workspace sessions, and ${coverage.transcript.withoutTranscript} carry no ${TRANSCRIPT_FILE}.`, + }); + } + if (coverage.transcript.timeUnobservedSessions > 0) { + warnings.push({ + code: "copilot-session-timestamps-unobserved", + message: `Copilot event timestamps were unobserved in ${coverage.transcript.timeUnobservedSessions} matched transcripts.`, + }); + } + return warnings; + } + + factsSourceCoverage(scope) { + return scope._copilotSourceCoverage ?? null; + } + + async analyze(options = {}) { + return runProviderAnalysis(this, options, { platform: "copilot", adapterVersion: "copilot-v1" }); + } +} + +export async function main(argv = process.argv.slice(2)) { + const { command = "sessions", options } = parseArgs(argv); + const analyzer = new CopilotSessionAnalyzer(); + const result = await runProviderCommand(analyzer, command, options); + await emitProviderResult({ provider: "Copilot", command, options, result }); + return result; +} + +const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isCli) { + main().catch((error) => { + process.stderr.write(`copilot session-analysis failed: ${error.stack ?? error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/session-analysis/selection-profile.mjs b/scripts/session-analysis/selection-profile.mjs index 3b62dbb..f421a3b 100644 --- a/scripts/session-analysis/selection-profile.mjs +++ b/scripts/session-analysis/selection-profile.mjs @@ -19,7 +19,7 @@ a declarative session selection plan. Raw prompts, commands, paths, and session identifiers never enter the profile. Options: - --platform + --platform Session platform (default: qoder) --workspace Target workspace (required) --since Exclude earlier sessions diff --git a/scripts/session-analysis/session-core-facts.mjs b/scripts/session-analysis/session-core-facts.mjs index 35cdac4..a90948f 100644 --- a/scripts/session-analysis/session-core-facts.mjs +++ b/scripts/session-analysis/session-core-facts.mjs @@ -21,6 +21,8 @@ export function createFactsRunContext(options = {}, platform = "unknown", provid qoder: process.env.QODER_SESSION_ID, claude: process.env.CLAUDE_SESSION_ID, cursor: process.env.CURSOR_SESSION_ID, + qwen: process.env.QWEN_SESSION_ID, + copilot: process.env.COPILOT_SESSION_ID, })[platform]; const excludedSessionId = String( options["exclude-session-id"] ?? options.excludeSessionId ?? environmentSessionId ?? "", diff --git a/scripts/session-analysis/usage-summary.mjs b/scripts/session-analysis/usage-summary.mjs index b1f2aae..1dcb279 100644 --- a/scripts/session-analysis/usage-summary.mjs +++ b/scripts/session-analysis/usage-summary.mjs @@ -12,7 +12,7 @@ Emit a bounded, read-only usage boundary as JSON. This command never accepts --output and never writes report or scratch files. Options: - --platform + --platform Session provider (default: qoder) --workspace Target workspace (default: current directory) --selection Selection strategy (default: all-eligible) diff --git a/templates/reporting/routing.md b/templates/reporting/routing.md index 55edd72..f1c9799 100644 --- a/templates/reporting/routing.md +++ b/templates/reporting/routing.md @@ -28,6 +28,6 @@ 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, or Cursor, or a portable visual is explicitly requested | renderer-owned `findings.json`, `report.md`, `report.html` | `html-visual.md` | +| Portable HTML report | Active host is Claude Code, Codex, Cursor, Qwen Code, or GitHub Copilot, 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 071f1bc..5e2d874 100644 --- a/test/agent-customize-architecture.test.mjs +++ b/test/agent-customize-architecture.test.mjs @@ -22,6 +22,8 @@ test("agent-customize inventory keeps host collectors behind provider modules", "scripts/agent-customize/providers/qoder.mjs", "scripts/agent-customize/providers/codex.mjs", "scripts/agent-customize/providers/claude.mjs", + "scripts/agent-customize/providers/qwen.mjs", + "scripts/agent-customize/providers/copilot.mjs", "scripts/agent-customize/providers/index.mjs", ]) { await assert.doesNotReject(() => readRepoFile(relativePath), `${relativePath} should exist`); @@ -36,6 +38,8 @@ test("agent-customize inventory keeps host collectors behind provider modules", assert.match(providerIndex, /qoder/u); assert.match(providerIndex, /codex/u); assert.match(providerIndex, /claude/u); + assert.match(providerIndex, /qwen/u); + assert.match(providerIndex, /copilot/u); }); test("host architecture docs keep matrix, providers, and thin shells separate", async () => { @@ -50,8 +54,8 @@ test("host architecture docs keep matrix, providers, and thin shells separate", assert.match(adapterReadme, /# Host Adapter Matrix/u); assert.match(adapterReadme, /`docs\/adapters\/qoder\.md`/u); assert.match(adapterReadme, /Codex \| Analysis-capable source-local host \| `\.codex-plugin\/`/u); - assert.match(adapterReadme, /npm package includes the Qoder, Claude Code,\s+Codex, Cursor, and Qwen plugin metadata roots/u); - assert.match(adapterReadme, /generated Qoder runtime\s+bundle\s+includes only the Qoder shell/u); + assert.match(adapterReadme, /npm package includes the Qoder, Claude Code,\s+Codex, Cursor, Qwen, and GitHub Copilot plugin metadata roots/u); + assert.match(adapterReadme, /generated\s+Qoder runtime bundle includes only the Qoder shell/u); assert.match(adapterReadme, /Cursor \| Analysis-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); @@ -67,16 +71,17 @@ test("host architecture docs keep matrix, providers, and thin shells separate", assert.match(directoryAdr, /scripts\/packaging\/` owns source-local[\s\S]*excluded from public package\/runtime/u); assert.match(architecture, /The Codex shell\s+owns local install\/discovery metadata only/u); - assert.match(architecture, /public npm\s+package ships all five plugin metadata roots[\s\S]*Qoder runtime bundle\s+includes only `\.qoder-plugin\/`/u); + assert.match(architecture, /public npm\s+package ships all six plugin metadata roots[\s\S]*Qoder runtime bundle\s+includes only `\.qoder-plugin\/`/u); assert.match(architecture, /do not create a generic detector or signal umbrella/u); assert.match(community, /`docs\/adapters\/README\.md` matrix row/u); - assert.match(community, /Public npm package includes all five current metadata roots[\s\S]*Qoder runtime bundle includes only `\.qoder-plugin\/`/u); + assert.match(community, /Public npm package includes all six current metadata roots[\s\S]*Qoder runtime bundle includes only `\.qoder-plugin\/`/u); assert.match(community, /owning `models\/\.md`, `scripts\/\/`, or `skills\/\/references\/`/u); - assert.match(glossary, /public npm package ships all five current metadata roots[\s\S]*Qoder runtime bundle includes only `\.qoder-plugin\/`/u); + assert.match(glossary, /public npm package ships all six current metadata roots[\s\S]*Qoder runtime bundle includes only `\.qoder-plugin\/`/u); assert.match(glossary, /Start with \[model routing\]\(\.\.\/models\/routing\.md\)/u); assert.match(adapterReadme, /Claude Code\s+\|/u); - assert.doesNotMatch(adapterReadme, /GitHub Copilot\s+\|/u); + assert.match(adapterReadme, /GitHub Copilot \| Analysis-capable source-local host \| `\.github\/plugin\/`[^\n]+platforms\/copilot\.mjs/u); + assert.match(directoryAdr, /\.github\/plugin\/\s+# \[active\] thin GitHub Copilot shell/u); assert.doesNotMatch(community, /`docs\/adapters\/\.md`\s+\| Discovery paths/u); assert.doesNotMatch(architecture, /Do not add `\.codex-plugin\/` until Codex has/u); assert.doesNotMatch(directoryAdr, /detector\/\s+# auxiliary detectors/u); diff --git a/test/agent-customize.test.mjs b/test/agent-customize.test.mjs index f2d8b29..1009ab7 100644 --- a/test/agent-customize.test.mjs +++ b/test/agent-customize.test.mjs @@ -1626,3 +1626,178 @@ test("Qwen provider collects user and project MCPs, skills, hooks, and rules", a await rm(fixture.root, { recursive: true, force: true }); } }); + +async function makeCopilotFixture() { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-agent-customize-copilot-")); + const copilotHome = path.join(root, ".copilot"); + const workspace = path.join(root, "workspace", "better-harness"); + + const pluginRoot = path.join(copilotHome, "installed-plugins", "acme", "delivery"); + await writeJson(path.join(pluginRoot, ".github", "plugin", "plugin.json"), { + name: "delivery", + version: "1.0.0", + displayName: "Delivery", + description: "Delivery workflow plugin.", + skills: "./skills/", + }); + await writeText( + path.join(pluginRoot, "skills", "ship-release", "SKILL.md"), + "---\nname: ship-release\ndescription: Ship a release.\n---\n", + ); + await writeJson(path.join(pluginRoot, ".mcp.json"), { + mcpServers: { deliveryMcp: { command: "node", args: ["mcp/server.cjs"] } }, + }); + + await writeJson(path.join(copilotHome, "config.json"), { + installedPlugins: [ + { + name: "delivery", + marketplace: "acme", + version: "1.0.0", + enabled: true, + installed_at: "2026-07-20T00:00:00.000Z", + cache_path: pluginRoot, + }, + { + name: "missing-plugin", + marketplace: "acme", + version: "0.1.0", + enabled: true, + cache_path: path.join(copilotHome, "installed-plugins", "acme", "missing-plugin"), + }, + ], + }); + + await writeText(path.join(copilotHome, "copilot-instructions.md"), "# User Copilot Guide\n\nPersonal defaults.\n"); + await writeText( + path.join(copilotHome, "skills", "user-skill", "SKILL.md"), + "---\nname: user-skill\ndescription: A personal skill.\n---\n", + ); + await writeJson(path.join(copilotHome, "mcp-config.json"), { + mcpServers: { userMcp: { command: "node", args: ["user.cjs"] } }, + }); + + await writeText(path.join(workspace, "AGENTS.md"), "# Agents\n\nProject guidance.\n"); + await writeText(path.join(workspace, ".github", "copilot-instructions.md"), "# Copilot\n\nProject Copilot guidance.\n"); + await writeText( + path.join(workspace, ".github", "instructions", "tests.instructions.md"), + "---\napplyTo: \"**/*.test.mjs\"\ndescription: Test guidance.\n---\n", + ); + await writeText( + path.join(workspace, ".github", "skills", "review-change", "SKILL.md"), + "---\nname: review-change\ndescription: Review a change.\n---\n", + ); + await writeText( + path.join(workspace, ".github", "agents", "reviewer.agent.md"), + "---\nname: reviewer\ndescription: Review code.\n---\n", + ); + await writeJson(path.join(workspace, ".github", "hooks", "guard.json"), { + hooks: { Stop: [{ hooks: [{ type: "command", command: "node scripts/review-trigger/cli.mjs" }] }] }, + }); + await writeJson(path.join(workspace, ".github", "mcp.json"), { + mcpServers: { projectMcp: { command: "node", args: ["project.cjs"] } }, + }); + + return { root, copilotHome, workspace }; +} + +test("Copilot inventory separates plugin, user, and project assets", async () => { + const fixture = await makeCopilotFixture(); + try { + const inventory = await collectAgentCustomizeInventory({ + provider: "copilot", + workspace: fixture.workspace, + copilotHome: fixture.copilotHome, + includeUserHome: true, + }); + + assert.equal(inventory.provider, "copilot"); + assert.equal(inventory.copilotHome, fixture.copilotHome); + assert.equal(inventory.diagnostics.installedPluginState, "copilot-config"); + + // The record whose cache path is absent must not become an installed plugin. + assert.equal(inventory.plugins.length, 1); + assert.equal(inventory.plugins[0].id, "acme/delivery"); + assert.equal(inventory.plugins[0].installMatch, "copilot-marketplace"); + assert.equal(inventory.plugins[0].skills.length, 1); + + const skillNames = inventory.manage.skills.map((skill) => skill.name).sort(); + assert.deepEqual(skillNames, ["review-change", "ship-release", "user-skill"]); + + const scopesByName = new Map(inventory.manage.skills.map((skill) => [skill.name, skill.scope])); + assert.equal(scopesByName.get("review-change"), "project"); + assert.equal(scopesByName.get("user-skill"), "user"); + assert.equal(scopesByName.get("ship-release"), "plugin"); + + const ruleNames = inventory.manage.rules.map((rule) => rule.name); + assert.ok(ruleNames.includes("AGENTS.md")); + assert.ok(ruleNames.includes("copilot-instructions.md")); + + const mcpNames = inventory.manage.mcps.map((mcp) => mcp.name).sort(); + assert.deepEqual(mcpNames, ["deliveryMcp", "projectMcp", "userMcp"]); + + assert.equal(inventory.manage.subagents.some((agent) => agent.name === "reviewer"), true); + assert.equal(inventory.manage.hooks.length > 0, true); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test("Copilot inventory without user-home authority keeps project scope only", async () => { + const fixture = await makeCopilotFixture(); + try { + const inventory = await collectAgentCustomizeInventory({ + provider: "copilot", + workspace: fixture.workspace, + copilotHome: fixture.copilotHome, + includeUserHome: false, + }); + + assert.equal(inventory.plugins.length, 0); + assert.equal(inventory.diagnostics.installedPluginState, "not-authorized"); + assert.deepEqual(inventory.manage.skills.map((skill) => skill.name), ["review-change"]); + assert.deepEqual(inventory.manage.mcps.map((mcp) => mcp.name), ["projectMcp"]); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test("agent-customize CLI honours --copilot-home instead of the real user home", async () => { + // Regression: the CLI advertised --copilot-home but never forwarded it, so an + // isolated home still resolved ~/.copilot and scanned the caller's real assets. + const fixture = await makeCopilotFixture(); + try { + const result = runAgentCustomizeCli([ + "inventory", + "--provider", + "copilot", + "--workspace", + fixture.workspace, + "--copilot-home", + fixture.copilotHome, + ]); + + assert.equal(result.status, 0, result.stderr); + const inventory = JSON.parse(result.stdout); + + // The resolved home must be the override, never the caller's real ~/.copilot. + assert.equal(inventory.copilotHome, fixture.copilotHome); + assert.notEqual(inventory.copilotHome, path.join(os.homedir(), ".copilot")); + + // The inventory itself must also come from the override. + assert.equal(inventory.diagnostics.installedPluginState, "copilot-config"); + assert.deepEqual(inventory.plugins.map((plugin) => plugin.id), ["acme/delivery"]); + assert.deepEqual( + inventory.manage.skills.map((skill) => skill.name).sort(), + ["review-change", "ship-release", "user-skill"], + ); + for (const item of [...inventory.manage.skills, ...inventory.manage.mcps]) { + assert.ok( + !item.evidence?.path || !item.evidence.path.startsWith(path.join(os.homedir(), ".copilot")), + `inventory item escaped the override home: ${item.evidence?.path}`, + ); + } + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); diff --git a/test/better-harness-skill.test.mjs b/test/better-harness-skill.test.mjs index 0af8c02..5c8ad87 100644 --- a/test/better-harness-skill.test.mjs +++ b/test/better-harness-skill.test.mjs @@ -77,7 +77,7 @@ test("non-Qoder providers default to validated durable HTML with an explicit inl 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, or Cursor/); + assert.match(routing, /Portable HTML report \| Active host is Claude Code, Codex, Cursor, Qwen Code, or GitHub Copilot/); 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/); diff --git a/test/coding-agent-platform-notes.test.mjs b/test/coding-agent-platform-notes.test.mjs index 2228516..06d56e2 100644 --- a/test/coding-agent-platform-notes.test.mjs +++ b/test/coding-agent-platform-notes.test.mjs @@ -43,7 +43,8 @@ test("session diagnostics keeps the shared workflow before platform source roots assertAfter(content, "~/.claude/projects", "## Platform Notes", "Sessions Diagnostics"); assertAfter(content, "~/.cursor/projects", "## Platform Notes", "Sessions Diagnostics"); assertAfter(content, "~/.qwen/projects", "## Platform Notes", "Sessions Diagnostics"); - assert.match(content, /Supported platforms: `qoder`, `codex`, `claude`, `cursor`, and `qwen`/); + assertAfter(content, "~/.copilot/session-state", "## Platform Notes", "Sessions Diagnostics"); + assert.match(content, /Supported platforms: `qoder`, `codex`, `claude`, `cursor`, `qwen`, and `copilot`/); assert.match(content, /Never decode Cursor `store\.db`/); assert.ok(content.indexOf("session-analysis.mjs sources") < content.indexOf("## Platform Notes")); }); diff --git a/test/fixtures/scripts-refactor-contract/root-help.txt b/test/fixtures/scripts-refactor-contract/root-help.txt index 25e8f1d..e864b42 100644 --- a/test/fixtures/scripts-refactor-contract/root-help.txt +++ b/test/fixtures/scripts-refactor-contract/root-help.txt @@ -20,8 +20,8 @@ Commands: repair-findings, record-fix-output, validate-canvas Project Evidence - session-analysis Collect and normalize Qoder, Codex, Claude, Cursor, and Qwen - session evidence + session-analysis Collect and normalize Qoder, Codex, Claude, Cursor, Qwen, and + Copilot session evidence dependency-governance Detect dependency governance files, automation, audit signals, and stale dependency evidence cloc Count code, comments, and blank lines diff --git a/test/fixtures/scripts-refactor-contract/session-help.txt b/test/fixtures/scripts-refactor-contract/session-help.txt index 3c7952e..66aab31 100644 --- a/test/fixtures/scripts-refactor-contract/session-help.txt +++ b/test/fixtures/scripts-refactor-contract/session-help.txt @@ -1,4 +1,4 @@ -Usage: session-analysis --platform --workspace [options] +Usage: session-analysis --platform --workspace [options] Commands: sources, sessions, facets, insights, facts, file-reads, show, events, claude-facets diff --git a/test/plugin-manifests.test.mjs b/test/plugin-manifests.test.mjs index 57c4328..2d4701e 100644 --- a/test/plugin-manifests.test.mjs +++ b/test/plugin-manifests.test.mjs @@ -105,6 +105,8 @@ test("host plugin manifests expose canonical Better Harness resources", () => { const cursor = readJson(".cursor-plugin/plugin.json"); const codex = readJson(".codex-plugin/plugin.json"); const qwen = readJson("qwen-extension.json"); + const copilot = readJson(".github/plugin/plugin.json"); + const copilotMarketplace = readJson(".github/plugin/marketplace.json"); const cursorMarketplace = readJson(".cursor-plugin/marketplace.json"); const packageJson = readJson("package.json"); const packageLock = readJson("package-lock.json"); @@ -159,6 +161,16 @@ test("host plugin manifests expose canonical Better Harness resources", () => { assert.equal(qwen.displayName, qoder.displayName); assert.equal(qwen.contextFileName, "QWEN.md"); assert.equal(qwen.skills, "./skills/"); + + assert.equal(copilot.name, qoder.name); + assert.equal(copilot.version, qoder.version); + assert.equal(copilot.description, qoder.description); + assert.deepEqual(copilot.author, qoder.author); + assert.equal(copilot.category, qoder.category); + assert.equal(copilot.skills, "./skills/"); + assert.equal(copilot.hooks, undefined); + assert.equal(copilot.license, "MIT"); + assert.equal(cursor.license, "MIT"); assert.equal(qoder.license, "MIT"); assert.equal(packageJson.license, "MIT"); @@ -169,7 +181,7 @@ test("host plugin manifests expose canonical Better Harness resources", () => { assert.equal(packageLock.packages[""].name, packageJson.name); assert.equal(packageLock.packages[""].license, packageJson.license); assert.match(packageJson.scripts["publish:dry-run"], /registry\.npmjs\.org/u); - for (const manifest of [qoder, claude, cursor, codex]) { + for (const manifest of [qoder, claude, cursor, codex, copilot]) { assert.equal(manifest.homepage, "https://github.com/QoderAI/better-harness"); assert.equal(manifest.repository, "https://github.com/QoderAI/better-harness"); } @@ -200,6 +212,17 @@ test("host plugin manifests expose canonical Better Harness resources", () => { assert.equal(claudeMarketplace.plugins[0].version, claude.version); assert.equal(claudeMarketplace.plugins[0].source, "./"); assert.deepEqual(claudeMarketplace.plugins[0].author, claude.author); + + assert.equal(copilotMarketplace.name, "better-harness"); + // Copilot marketplace owners accept name and email only. + assert.deepEqual(copilotMarketplace.owner, { name: copilot.author.name, email: copilot.author.email }); + assert.equal(copilotMarketplace.metadata.version, copilot.version); + assert.equal(copilotMarketplace.plugins.length, 1); + assert.equal(copilotMarketplace.plugins[0].name, copilot.name); + assert.equal(copilotMarketplace.plugins[0].description, copilot.description); + assert.equal(copilotMarketplace.plugins[0].version, copilot.version); + assert.equal(copilotMarketplace.plugins[0].source, "./"); + assert.equal(copilotMarketplace.plugins[0].skills, "./skills/"); }); test("npm packaging includes every host manifest while the runtime bundle stays Qoder-specific", () => { @@ -210,6 +233,7 @@ test("npm packaging includes every host manifest while the runtime bundle stays ".claude-plugin/", ".codex-plugin/", ".cursor-plugin/", + ".github/plugin/", ".qoder-plugin/", "qwen-extension.json", "AGENTS.md", @@ -240,6 +264,7 @@ test("npm packaging includes every host manifest while the runtime bundle stays assert.doesNotMatch(bundleScript, /"\.claude-plugin"/u); assert.doesNotMatch(bundleScript, /"\.codex-plugin"/u); assert.doesNotMatch(bundleScript, /"\.cursor-plugin"/u); + assert.doesNotMatch(bundleScript, /"\.github"/u); assert.doesNotMatch(bundleScript, /"schemas"/u); const verifyScript = readText("scripts/npm-package/verify-pack.mjs"); @@ -255,6 +280,9 @@ test("npm packaging includes every host manifest while the runtime bundle stays assert.match(verifyScript, /package\/\.claude-plugin\/plugin\.json/u); assert.match(verifyScript, /package\/\.claude-plugin\/marketplace\.json/u); assert.match(verifyScript, /package\/\.codex-plugin\/plugin\.json/u); + assert.match(verifyScript, /package\/\.github\/plugin\/plugin\.json/u); + assert.match(verifyScript, /package\/\.github\/plugin\/marketplace\.json/u); + assert.match(verifyScript, /"\.github\/plugin\/plugin\.json"/u); assert.match(verifyScript, /package\/\.cursor-plugin\/plugin\.json/u); assert.match(verifyScript, /package\/\.cursor-plugin\/marketplace\.json/u); assert.match(verifyScript, /package\/qwen-extension\.json/u); diff --git a/test/scripts-refactor-contract.test.mjs b/test/scripts-refactor-contract.test.mjs index 4eaed8f..1012fda 100644 --- a/test/scripts-refactor-contract.test.mjs +++ b/test/scripts-refactor-contract.test.mjs @@ -70,12 +70,12 @@ test("scripts refactor contract freezes machine-readable CLI output", () => { { label: "command inventory", args: ["commands", "--json"], - sha256: "6a015f7a50ccf60bcdc02f490e16790f011e84925e12440f4967812aad16e408", + sha256: "544235e08c2248f15a757bea00c584b7221e0011a84eec34b0b8515385a9b4dd", }, { label: "OpenCLI schema", args: ["schema"], - sha256: "cc5788593fe6f305db290974d4c808647b190a2850c621433f3269dc416ab179", + sha256: "34bd94d621f400ce4a7975bbb9d9f1681b7bd41cef52f99befbfe578944b2825", }, { label: "Harness command description", diff --git a/test/session-analysis-providers.test.mjs b/test/session-analysis-providers.test.mjs index ff72622..53abae4 100644 --- a/test/session-analysis-providers.test.mjs +++ b/test/session-analysis-providers.test.mjs @@ -18,6 +18,10 @@ import { QwenSessionAnalyzer, workspaceToQwenSlugVariants, } from "../scripts/session-analysis/platforms/qwen.mjs"; +import { + CopilotSessionAnalyzer, + parseWorkspaceDescriptor, +} from "../scripts/session-analysis/platforms/copilot.mjs"; import { measureLongSessionRows } from "../scripts/session-analysis/long-sessions.mjs"; async function fixtureRoot(prefix) { @@ -33,9 +37,11 @@ test("root dispatcher creates Claude and Cursor provider analyzers", async () => assert.ok(await createAnalyzer("claude") instanceof ClaudeSessionAnalyzer); assert.ok(await createAnalyzer("cursor") instanceof CursorSessionAnalyzer); assert.ok(await createAnalyzer("qwen") instanceof QwenSessionAnalyzer); + assert.ok(await createAnalyzer("copilot") instanceof CopilotSessionAnalyzer); assert.ok(await createCapabilityAnalyzer("claude") instanceof ClaudeSessionAnalyzer); assert.ok(await createCapabilityAnalyzer("cursor") instanceof CursorSessionAnalyzer); assert.ok(await createCapabilityAnalyzer("qwen") instanceof QwenSessionAnalyzer); + assert.ok(await createCapabilityAnalyzer("copilot") instanceof CopilotSessionAnalyzer); }); test("Claude, Cursor, and Qwen workspace slugs cover Unix and Windows layouts", () => { @@ -486,3 +492,215 @@ test("Qwen provider rejects a transcript whose embedded cwd belongs to another w const result = await new QwenSessionAnalyzer().analyze({ command: "sources", workspace, home }); assert.equal(result.sessions.length, 0); }); + +test("Copilot workspace descriptors parse the session cwd binding", () => { + const descriptor = parseWorkspaceDescriptor([ + "id: 831cdd03-a101-4246-8809-5d7a80dd48be", + "cwd: C:\\workspace\\project", + "client_name: github/autopilot", + "summary_count: 5", + ].join("\n")); + assert.equal(descriptor.id, "831cdd03-a101-4246-8809-5d7a80dd48be"); + assert.equal(descriptor.cwd, "C:\\workspace\\project"); +}); + +test("Copilot provider pairs tool lifecycle, hooks, and subagent delegation", async () => { + const root = await fixtureRoot("copilot-provider-"); + const home = path.join(root, ".copilot"); + const workspace = path.join(root, "workspace", "project"); + const sessionDir = path.join(home, "session-state", "session-a"); + await mkdir(workspace, { recursive: true }); + await mkdir(sessionDir, { recursive: true }); + await writeFile(path.join(sessionDir, "workspace.yaml"), `id: session-a\ncwd: ${workspace}\n`); + await writeJsonl(path.join(sessionDir, "events.jsonl"), [ + { type: "session.start", id: "e1", timestamp: "2026-07-20T01:00:00.000Z", data: { sessionId: "session-a", selectedModel: "test-model", context: { cwd: workspace } } }, + { type: "user.message", id: "e2", timestamp: "2026-07-20T01:00:01.000Z", data: { content: "run the tests" } }, + { type: "hook.start", id: "e3", timestamp: "2026-07-20T01:00:02.000Z", data: { hookInvocationId: "h1", hookType: "userPromptSubmitted" } }, + { type: "hook.end", id: "e4", timestamp: "2026-07-20T01:00:03.000Z", data: { hookInvocationId: "h1", hookType: "userPromptSubmitted", success: true } }, + { type: "assistant.message", id: "e5", timestamp: "2026-07-20T01:00:04.000Z", data: { model: "test-model", content: "running", messageId: "m1", requestId: "r1", outputTokens: 128 } }, + { type: "tool.execution_start", id: "e6", timestamp: "2026-07-20T01:00:05.000Z", data: { toolCallId: "t1", toolName: "bash", arguments: { command: "npm test" } } }, + { type: "tool.execution_complete", id: "e7", timestamp: "2026-07-20T01:00:06.000Z", data: { toolCallId: "t1", success: true, result: { content: "Tests: 1 failed, 2 passed" } } }, + { type: "subagent.started", id: "e8", timestamp: "2026-07-20T01:00:07.000Z", data: { toolCallId: "s1", agentName: "research" } }, + { type: "subagent.completed", id: "e9", timestamp: "2026-07-20T01:00:08.000Z", data: { toolCallId: "s1", agentName: "research", totalTokens: 42, totalToolCalls: 3 } }, + { type: "brand.new.event", id: "e10", timestamp: "2026-07-20T01:00:09.000Z", data: {} }, + { type: "assistant.message", id: "e11", timestamp: "2026-07-20T01:00:10.000Z", data: { model: "test-model", content: "done", messageId: "m2" } }, + ]); + + const analyzer = new CopilotSessionAnalyzer(); + const scope = await analyzer.resolveScope({ workspace, home }); + const roots = await analyzer.discoverSourceRoots(scope); + const sessions = await analyzer.discoverSessions(scope, roots); + assert.equal(sessions.length, 1); + assert.equal(sessions[0].sessionId, "session-a"); + + const events = await analyzer.readSession(sessions[0], scope, { includeCommandText: true }); + const byType = new Map(events.map((event) => [event.type, event])); + assert.equal(byType.get("tool.call").toolName, "bash"); + assert.equal(byType.get("tool.call").commandText, "npm test"); + assert.equal(byType.get("tool.result").success, true); + assert.deepEqual(byType.get("tool.result").resultFacts, { testsFailed: 1, testsPassed: 2 }); + assert.equal(byType.get("hook.call").hookEvent, "userPromptSubmitted"); + assert.equal(byType.get("hook.result").success, true); + assert.equal(byType.get("subagent.start").subagentName, "research"); + assert.equal(byType.get("subagent.stop").subagentTotalTokens, 42); + assert.ok(byType.has("metadata.brand.new.event")); + + // Copilot reports output tokens per assistant message. They ride a companion + // response event because `isModelRequestEvent` ignores plain assistant events, + // and only the observed field is carried -- no input tokens or cost. + const responses = events.filter((event) => event.type === "model.response.completed"); + assert.equal(responses.length, 1); + assert.deepEqual(responses[0].modelUsage, { outputTokens: 128 }); + assert.equal(responses[0].usageFieldsObserved, true); + assert.equal(responses[0].responseId, "m1"); + assert.equal(responses[0].requestId, "r1"); + assert.equal(responses[0].model, "test-model"); + const assistants = events.filter((event) => event.type === "assistant"); + assert.equal(assistants.length, 2); + // Model attribution moves to the companion so one response is not counted twice. + assert.equal(assistants[0].model, undefined); + // An assistant message without observed usage keeps its own attribution. + assert.equal(assistants[1].model, "test-model"); + + const coverage = analyzer.factsSourceCoverage(scope); + assert.equal(coverage.status, "observed"); + assert.equal(coverage.usage.perResponseUsageObserved, true); + assert.deepEqual(coverage.usage.perResponseUsageFields, ["outputTokens"]); + assert.equal(coverage.transcript.withConversation, 1); + assert.equal(coverage.transcript.withRequest, 1); + assert.equal(coverage.transcript.terminalOnly, 0); + assert.equal(coverage.transcript.unreadable, 0); +}); + +test("Copilot provider maps the permission request and result pair without payloads", async () => { + const root = await fixtureRoot("copilot-permission-"); + const home = path.join(root, ".copilot"); + const workspace = path.join(root, "workspace", "project"); + const sessionDir = path.join(home, "session-state", "session-p"); + await mkdir(workspace, { recursive: true }); + await mkdir(sessionDir, { recursive: true }); + await writeFile(path.join(sessionDir, "workspace.yaml"), `id: session-p\ncwd: ${workspace}\n`); + await writeJsonl(path.join(sessionDir, "events.jsonl"), [ + { type: "user.message", id: "e1", timestamp: "2026-07-20T01:00:00.000Z", data: { content: "read it" } }, + { + type: "permission.requested", + id: "e2", + timestamp: "2026-07-20T01:00:01.000Z", + data: { + requestId: "req-1", + permissionRequest: { kind: "read", toolCallId: "t1", intention: "Read file: C:\\secret\\notes.md", path: "C:\\secret\\notes.md" }, + promptRequest: { kind: "path", accessKind: "read", paths: ["C:\\secret\\notes.md"], toolCallId: "t1" }, + }, + }, + { type: "permission.completed", id: "e3", timestamp: "2026-07-20T01:00:02.000Z", data: { requestId: "req-1", toolCallId: "t1", result: { kind: "approved" } } }, + { + type: "permission.requested", + id: "e4", + timestamp: "2026-07-20T01:00:03.000Z", + data: { + requestId: "req-2", + permissionRequest: { kind: "shell", toolCallId: "t2", intention: "Run: rm -rf /" }, + promptRequest: { kind: "commands", commands: ["rm -rf /"], toolCallId: "t2" }, + }, + }, + { type: "permission.completed", id: "e5", timestamp: "2026-07-20T01:00:04.000Z", data: { requestId: "req-2", toolCallId: "t2", result: { kind: "denied-interactively-by-user" } } }, + // A re-prompt reuses the tool call id; both requests must survive dedupe. + { + type: "permission.requested", + id: "e6", + timestamp: "2026-07-20T01:00:05.000Z", + data: { requestId: "req-3", permissionRequest: { kind: "shell", toolCallId: "t2" } }, + }, + { type: "permission.completed", id: "e7", timestamp: "2026-07-20T01:00:06.000Z", data: { requestId: "req-3", toolCallId: "t2", result: { kind: "approved-for-location" } } }, + { type: "session.permissions_changed", id: "e8", timestamp: "2026-07-20T01:00:07.000Z", data: {} }, + ]); + + const analyzer = new CopilotSessionAnalyzer(); + const scope = await analyzer.resolveScope({ workspace, home }); + const roots = await analyzer.discoverSourceRoots(scope); + const sessions = await analyzer.discoverSessions(scope, roots); + const events = await analyzer.readSession(sessions[0], scope, { includeCommandText: true }); + + const permissions = events.filter((event) => event.type === "control.permission"); + assert.equal(permissions.length, 6); + assert.deepEqual( + permissions.map((event) => [event.lifecyclePhase, event.permissionRequestId, event.permissionKind ?? null, event.permissionDecision ?? null]), + [ + ["request", "req-1", "read", null], + ["result", "req-1", null, "allowed"], + ["request", "req-2", "shell", null], + ["result", "req-2", null, "denied"], + ["request", "req-3", "shell", null], + ["result", "req-3", null, "allowed"], + ], + ); + // The lifecycle stays additive: the mode/permission change event is unaffected. + assert.ok(events.some((event) => event.type === "control.change")); + + // No prompt payload may survive normalization, even with content opted in. + const serialized = JSON.stringify(permissions); + for (const payload of ["secret", "notes.md", "rm -rf", "Read file", "intention", "paths"]) { + assert.ok(!serialized.includes(payload), `permission events leaked ${payload}`); + } + // Tool call ids are deliberately not carried on `toolInvocationId`: dedupe keys + // on that field and a re-prompt would be dropped as a duplicate. + assert.ok(permissions.every((event) => event.toolInvocationId === undefined)); +}); + +test("Copilot provider keeps transcript-less workspace sessions explicit", async () => { + const root = await fixtureRoot("copilot-partial-"); + const home = path.join(root, ".copilot"); + const workspace = path.join(root, "workspace", "project"); + const withTranscript = path.join(home, "session-state", "session-a"); + const withoutTranscript = path.join(home, "session-state", "session-b"); + await mkdir(workspace, { recursive: true }); + await mkdir(withTranscript, { recursive: true }); + await mkdir(withoutTranscript, { recursive: true }); + await writeFile(path.join(withTranscript, "workspace.yaml"), `id: session-a\ncwd: ${workspace}\n`); + await writeFile(path.join(withoutTranscript, "workspace.yaml"), `id: session-b\ncwd: ${workspace}\n`); + await writeJsonl(path.join(withTranscript, "events.jsonl"), [ + { type: "user.message", id: "e1", timestamp: "2026-07-20T01:00:00.000Z", data: { content: "hello" } }, + ]); + + const analyzer = new CopilotSessionAnalyzer(); + const scope = await analyzer.resolveScope({ workspace, home }); + const roots = await analyzer.discoverSourceRoots(scope); + const sessions = await analyzer.discoverSessions(scope, roots); + assert.equal(sessions.length, 1); + + const coverage = analyzer.factsSourceCoverage(scope); + assert.equal(coverage.status, "partial"); + assert.equal(coverage.transcript.workspaceSessions, 2); + assert.equal(coverage.transcript.withoutTranscript, 1); + // The missing transcript survives inside the canonical contract instead of + // being dropped when public facts are bounded. + assert.equal(coverage.transcript.relevantSessions, 2); + assert.equal(coverage.transcript.withConversation, 1); + assert.equal(coverage.transcript.withRequest, 0); + assert.equal(coverage.transcript.unreadable, 1); + assert.equal(coverage.transcript.terminalOnly, 0); + assert.equal(coverage.usage.perResponseUsageObserved, false); + + const warnings = await analyzer.analysisWarnings(scope, roots, sessions); + assert.ok(warnings.some((warning) => warning.code === "copilot-session-transcript-partial")); + assert.ok(warnings.some((warning) => warning.code === "copilot-per-response-usage-partial")); +}); + +test("Copilot provider ignores sessions from another workspace", async () => { + const root = await fixtureRoot("copilot-foreign-"); + const home = path.join(root, ".copilot"); + const workspace = path.join(root, "workspace", "project"); + const sessionDir = path.join(home, "session-state", "session-foreign"); + await mkdir(workspace, { recursive: true }); + await mkdir(sessionDir, { recursive: true }); + await writeFile(path.join(sessionDir, "workspace.yaml"), `id: session-foreign\ncwd: ${path.join(root, "workspace", "other")}\n`); + await writeJsonl(path.join(sessionDir, "events.jsonl"), [ + { type: "user.message", id: "e1", timestamp: "2026-07-20T01:00:00.000Z", data: { content: "foreign" } }, + ]); + + const analyzer = new CopilotSessionAnalyzer(); + const scope = await analyzer.resolveScope({ workspace, home }); + const roots = await analyzer.discoverSourceRoots(scope); + assert.equal((await analyzer.discoverSessions(scope, roots)).length, 0); + assert.equal(analyzer.factsSourceCoverage(scope).status, "absent"); +}); diff --git a/test/style-templates.test.mjs b/test/style-templates.test.mjs index 9878b33..9c2f631 100644 --- a/test/style-templates.test.mjs +++ b/test/style-templates.test.mjs @@ -75,7 +75,7 @@ test("harness report routing owns output-mode selection and exclusions", () => { assert.match(reportRouting, /Choose exactly one output route/); assert.match(reportRouting, /Qoder Canvas report/); assert.match(reportRouting, /Portable HTML report/); - assert.match(reportRouting, /Active host is Claude Code, Codex, or Cursor/); + assert.match(reportRouting, /Active host is Claude Code, Codex, Cursor, Qwen Code, or GitHub Copilot/); assert.match(reportRouting, /Markdown only/); assert.match(reportRouting, /Inline only/); assert.match(reportRouting, /inline analysis writes nothing/);