From ed6a70579b432199cfda57122fc8ac474eb08a07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=B3=E5=B4=87=E5=8A=9B?= Date: Tue, 28 Jul 2026 17:05:52 +0800 Subject: [PATCH 1/5] feat(host): add Qwen Code host adapter Add Qwen Code as a fifth supported host alongside Qoder, Codex, Claude Code, and Cursor. Session analyzer (scripts/session-analysis/platforms/qwen.mjs) parses ~/.qwen/projects//chats/*.jsonl transcripts using the message.parts functionCall/functionResponse format and top-level toolCallResult, mapping usageMetadata to standard usage fields. Asset collector (scripts/agent-customize/providers/qwen.mjs) inventories ~/.qwen/{skills,agents,rules,commands,hooks,extensions} plus workspace .qwen, QWEN.md, AGENTS.md, and ~/.mcp.json. New files: .qwen-plugin/plugin.json, session-analysis/platforms/qwen.mjs, agent-customize/providers/qwen.mjs, references/agent-customize/platforms/qwen.md. Updated: registration points, platform whitelists, help/USAGE text, docs (sessions-diagnostics, adapters/README matrix, routing, ARCHITECTURE, community, glossary), and 7 test files. 832 tests pass; pack:verify passes. --- .qwen-plugin/plugin.json | 44 +++ docs/ARCHITECTURE.md | 2 +- docs/adapters/README.md | 21 +- docs/adrs/directory-structure.md | 4 +- docs/community.md | 2 +- docs/concepts.md | 2 +- docs/glossary.md | 4 +- package.json | 1 + references/agent-customize/platforms/qwen.md | 87 ++++ references/agent-customize/routing.md | 26 ++ references/loop-engineering/loop-discovery.md | 2 +- .../session-evidence/sessions-diagnostics.md | 32 +- scripts/agent-customize/cli.mjs | 2 + scripts/agent-customize/providers/index.mjs | 2 + scripts/agent-customize/providers/qwen.mjs | 266 +++++++++++++ scripts/agent-lint/cli.mjs | 2 +- scripts/better-harness-cli/registry.mjs | 2 +- .../coding-agent-practices/asset-baseline.mjs | 6 +- .../asset-integrity.mjs | 6 +- scripts/coding-agent-practices/inventory.mjs | 10 +- .../evidence-bundle/agent-customize.mjs | 2 +- .../harness-analysis/evidence-bundle/cli.mjs | 4 +- .../evidence-bundle/contract.mjs | 2 +- scripts/harness-analysis/report-quality.mjs | 2 +- scripts/harness-analysis/report-run.mjs | 7 +- scripts/harness-analysis/task-loop-report.mjs | 2 +- scripts/harness-analysis/task-loop-source.mjs | 17 +- scripts/npm-package/verify-pack.mjs | 3 + scripts/session-analysis.mjs | 11 +- scripts/session-analysis/analyzer.mjs | 15 +- .../lifecycle-demand-signals.mjs | 2 +- scripts/session-analysis/platforms/qwen.mjs | 374 ++++++++++++++++++ .../session-analysis/selection-profile.mjs | 2 +- scripts/session-analysis/usage-summary.mjs | 2 +- test/agent-asset-baseline.test.mjs | 31 ++ test/agent-asset-integrity.test.mjs | 33 ++ test/agent-customize-architecture.test.mjs | 10 +- test/agent-customize.test.mjs | 214 ++++++++++ test/better-harness-evidence-bundle.test.mjs | 23 ++ test/coding-agent-platform-notes.test.mjs | 3 +- .../scripts-refactor-contract/root-help.txt | 4 +- .../session-help.txt | 2 +- test/plugin-manifests.test.mjs | 19 +- test/scripts-refactor-contract.test.mjs | 4 +- test/session-analysis-providers.test.mjs | 100 ++++- 45 files changed, 1342 insertions(+), 69 deletions(-) create mode 100644 .qwen-plugin/plugin.json create mode 100644 references/agent-customize/platforms/qwen.md create mode 100644 scripts/agent-customize/providers/qwen.mjs create mode 100644 scripts/session-analysis/platforms/qwen.mjs diff --git a/.qwen-plugin/plugin.json b/.qwen-plugin/plugin.json new file mode 100644 index 0000000..432e8a6 --- /dev/null +++ b/.qwen-plugin/plugin.json @@ -0,0 +1,44 @@ +{ + "name": "better-harness", + "version": "0.3.0", + "description": "Build an AI-ready engineering system for safe coding-agent delivery and continuous software improvement.", + "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": [ + "qoder-plugin", + "better-harness", + "ai-delivery", + "continuous-improvement", + "agent-harness", + "change-confidence" + ], + "skills": "./skills/", + "interface": { + "displayName": "Better Harness", + "shortDescription": "Evaluate and improve coding-agent delivery readiness.", + "longDescription": "Use the Better Harness skill to analyze repositories, agent workflows, host assets, guardrails, validation habits, and self-improvement signals for AI delivery readiness.", + "developerName": "Qoder", + "category": "Coding", + "capabilities": [ + "Interactive", + "Read", + "Write" + ], + "defaultPrompt": [ + "Evaluate this repository's AI delivery readiness.", + "Draft a repair plan for a Harness finding.", + "Analyze this project's coding-agent workflow." + ], + "websiteURL": "https://github.com/QoderAI/better-harness", + "privacyPolicyURL": "https://qoder.com/en/privacy-policy", + "termsOfServiceURL": "https://qoder.com/product-service", + "brandColor": "#0F766E", + "screenshots": [] + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cc6b7e1..a4397ec 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 four plugin metadata roots, while the Qoder runtime bundle + package ships all five 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 04cee50..b14ddda 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, and Cursor host -boundaries. Do not create `docs/adapters/claude-code.md`, -`docs/adapters/codex.md`, `docs/adapters/qoder.md`, or -`docs/adapters/cursor.md` by default. +This is the single entry point for Claude Code, Codex, Qoder, Cursor, and Qwen +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. Host differences enter only this matrix, capability-local configured-asset providers, real session-evidence adapters, and output modes. Canonical product @@ -11,8 +11,8 @@ judgment stays in `skills/`, `models/`, `references/`, `templates/`, and `scripts//`. The `@qoderai/better-harness` npm package includes the Qoder, Claude Code, -Codex, and Cursor plugin metadata roots. The generated Qoder runtime bundle -includes only the Qoder shell, `.qoder-plugin/`; non-Qoder generated host +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. @@ -22,6 +22,7 @@ repository's native marketplace manifest. | Codex | Analysis-capable source-local host | `.codex-plugin/` | `scripts/agent-customize/providers/codex.mjs` | `scripts/session-analysis/platforms/codex.mjs` | self-contained HTML + Markdown | `.codex` + `.agents` + `AGENTS.md` | `harness prepare --platform codex` -> finalize with `html-report` validation | | Qoder | First-class product host | `.qoder-plugin/` | `scripts/agent-customize/providers/qoder.mjs` | `scripts/session-analysis/platforms/qoder.mjs` | `better-harness` | `.qoder/rules` + `AGENTS.md` + output templates | `better-harness harness render --mode qoder-canvas --validate` | | Cursor | Analysis-capable source-local host | `.cursor-plugin/` | `scripts/agent-customize/providers/cursor.mjs` | `scripts/session-analysis/platforms/cursor.mjs` | self-contained HTML + Markdown | `.cursor` + `.codex` compatibility + `AGENTS.md` | `agent --plugin-dir . --mode ask --print` -> Cursor evidence bundle -> validated `html` render | +| Qwen Code | Analysis-capable source-local host | `.qwen-plugin/` | `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 | ## Discovery And Evidence @@ -48,6 +49,12 @@ repository's native marketplace manifest. evidence comes from `scripts/session-analysis/platforms/cursor.mjs`, which keeps transcript, metadata, and audit coverage explicit when local identities do not join. +- Qwen Code configured assets are inventoried through + `scripts/agent-customize/providers/qwen.mjs`. Session evidence comes from + `scripts/session-analysis/platforms/qwen.mjs`, which reads workspace-matching + JSONL transcripts under `~/.qwen/projects//chats/`. The `.qwen-plugin/` + shell is install/discovery metadata included in the public npm package; it + does not own Qwen evidence collection. ## Output Modes @@ -55,7 +62,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 visual output contract, covering +- `html-visual.md`: portable Claude Code/Codex/Cursor/Qwen 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 1334e92..11e461c 100644 --- a/docs/adrs/directory-structure.md +++ b/docs/adrs/directory-structure.md @@ -71,7 +71,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 host adapters + platforms/.mjs # Qoder/Codex/Claude/Cursor/Qwen host adapters ides// # target editor-local evidence not covered by host adapters / # [target] new capability owner cli.mjs # use cli.mjs for new capabilities @@ -208,7 +208,7 @@ Use the tree first. These rules resolve common collisions: - 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 four plugin metadata roots, while the Qoder + public npm package ships all five 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 a56829c..373fc5c 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/`, future lifecycle shells | Install/discovery metadata and pointers to canonical owners | Public npm package includes all four 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/`, `.qwen-plugin/`, 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 | ## Non-Extension Boundaries diff --git a/docs/concepts.md b/docs/concepts.md index 1c8f803..f5eb3bf 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, or Cursor session behavior | +| Session evidence | `better-harness session-analysis` | Normalize Qoder, Codex, Claude, Cursor, or Qwen 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 a47083b..ae585df 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, or Cursor agent session behavior into evidence. | [scripts/session-analysis](../scripts/session-analysis) | +| `session-analysis` | Normalizes Qoder, Codex, Claude, Cursor, or Qwen 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/`, or a future lifecycle shell) that exposes canonical behavior without owning product logic; the public npm package ships all four 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/`, `.qwen-plugin/`, 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) | | 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/package.json b/package.json index cac42a1..5b18f63 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ ".codex-plugin/", ".cursor-plugin/", ".qoder-plugin/", + ".qwen-plugin/", "AGENTS.md", "CHANGELOG.md", "CODE_OF_CONDUCT.md", diff --git a/references/agent-customize/platforms/qwen.md b/references/agent-customize/platforms/qwen.md new file mode 100644 index 0000000..3bcaff9 --- /dev/null +++ b/references/agent-customize/platforms/qwen.md @@ -0,0 +1,87 @@ +# Qwen Code Best Practices + +Use this file for Qwen Code-specific operating practice. Use `../routing.md` +for host-neutral owner selection and `codex.md`/`claude.md` for other host +references. Do not copy Qwen-only workflow advice into shared docs unless there +is matching surface evidence. + +## Operating Frame + +Treat Qwen Code as a configured teammate, not a one-off assistant. Start with +the right task context, move repeated guidance into `QWEN.md` and `AGENTS.md`, +configure Qwen Code for the real workflow, connect external systems through +MCP, turn repeated work into Skills and extensions, and automate only stable +workflows. + +## 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 + Qwen Code 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 + +Use `QWEN.md` and `AGENTS.md` for repository guidance that should load +automatically: + +- repo layout and important directories +- build, test, lint, and local run commands +- engineering conventions and review expectations +- safety constraints and do-not rules +- what "done" means and how to verify work + +Keep context files short and practical. Put large or conditional detail in +linked references. Use project-level `.qwen/settings.json` for repo-specific +behavior and global `~/.qwen/settings.json` for personal defaults. + +When Qwen Code repeats a mistake, update durable guidance only when the lesson +is reusable. + +## Configuration + +Qwen Code configuration should match the actual environment: + +- Use `~/.qwen/settings.json` for personal defaults (model, approval mode, + permissions). +- Use `/.qwen/settings.json` for repo-specific behavior. +- Use `.qwen/` for project-scoped skills, agents, hooks, and worktrees. +- Keep approval and sandbox settings tight until a trusted workflow needs more + access. + +## Skills and Extensions + +Turn a repeated workflow into a Skill when it has stable triggers, inputs, +steps, outputs, and validation. Install extensions from marketplaces or local +sources; each extension records its install marker under +`~/.qwen/extensions//.qwen-extension-install.json` with a `source` +pointer to the real plugin root. + +## External Context + +Use MCP when Qwen Code needs context or actions outside the repository. MCP +configuration lives in `~/.mcp.json` (user) or `/.mcp.json` (project). +Start with one or two MCP tools that remove a real repeated manual step. + +## Session Controls + +Keep one Qwen Code session per coherent unit of work. Session transcripts are +recorded as JSONL under `~/.qwen/projects//chats/`. The slug +replaces path separators (and `.`/`_`) with `-`. Use worktrees when concurrent +sessions could edit the same files. Use subagents for bounded exploration, +testing, or independent review. + +## Harness Projection + +For readiness reports, separate static presence from quality and execution: + +- `QWEN.md`, `AGENTS.md`, `.qwen/settings.json`, Skills, extensions, MCP config, + and hooks are configured-surface evidence. +- Test output, build logs, reviewed diffs, session transcripts, and automation + histories are execution evidence. +- Do not claim a Qwen Code practice is effective from file presence alone. diff --git a/references/agent-customize/routing.md b/references/agent-customize/routing.md index fbaf3e1..d47c419 100644 --- a/references/agent-customize/routing.md +++ b/references/agent-customize/routing.md @@ -310,3 +310,29 @@ Inspect configured surfaces before projecting readiness evidence: `memory-review.md` before treating it as a user preference, applied lesson, or current project fact. - Session, diff, test, build, and review evidence for observed execution. + +## Qwen Asset Route + +For Qwen Code-specific actions, use `platforms/qwen.md` as the operating +practice reference for prompt shape, `QWEN.md`, `.qwen` config, testing and +review loops, MCP, Skills, automations, worktrees, and subagents. Presence is +not execution proof. + +Inspect configured surfaces before projecting readiness evidence: + +- `QWEN.md` and `AGENTS.md` for durable repo context. +- `.qwen/settings.json` and user settings for model, approval, sandbox, MCP + servers, and defaults. +- `.qwen/skills`, project skills, user skills, and extension skills for + repeatable workflows. +- MCP configuration (`~/.mcp.json` and project `.mcp.json`) and connector + availability for external context. +- Qwen extensions under `~/.qwen/extensions/` for installed plugin metadata; + each extension carries a `.qwen-extension-install.json` marker with a + `source` pointer to the real plugin root. +- 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 +Qwen global assets such as `~/.qwen/skills` or `~/.qwen/hooks`, installed +extensions, or memories. Keep configured inventory evidence separate from +observed session behavior. diff --git a/references/loop-engineering/loop-discovery.md b/references/loop-engineering/loop-discovery.md index b3fb3e2..5827862 100644 --- a/references/loop-engineering/loop-discovery.md +++ b/references/loop-engineering/loop-discovery.md @@ -18,7 +18,7 @@ already obvious. session signal, report row, local entropy hint, or repeated-work suspicion that still needs an evidence pack. - Use `../session-evidence/sessions-diagnostics.md` to gather or inspect - Qoder, Codex, Claude, or Cursor session evidence. + Qoder, Codex, Claude, Cursor, or Qwen session evidence. - Return here only to decide whether the evidence proves a loop and who owns it. - Use `../agent-customize/skill-discovery.md` only after the decision is `Create Skill` or `Extend Skill`. diff --git a/references/session-evidence/sessions-diagnostics.md b/references/session-evidence/sessions-diagnostics.md index aeb9ac1..7b3a64a 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`, and `cursor`. Do not invent +Supported platforms: `qoder`, `codex`, `claude`, `cursor`, and `qwen`. 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. @@ -229,3 +229,21 @@ Never decode Cursor `store.db` or join global audit records without a matching workspace transcript identity. Route configured Cursor rules, Skills, hooks, plugins, and other project/user assets through `../agent-customize/global-assets.md`; configured presence does not prove use. + +### Qwen + +For Qwen Code, the analyzer reads workspace-matching JSONL transcripts under +`~/.qwen/projects//chats/**/*.jsonl` and verifies the embedded +`cwd` before admitting a session. The analyzer computes the workspace slug by +replacing path separators with `-`, so `/home/admin/workspace` maps to +`~/.qwen/projects/-home-admin-workspace/chats/*.jsonl`. Qwen Code does not +maintain a separate audit log; transcript records are the primary lifecycle +source, with tool calls carried in `message.parts` function call entries and +tool results in top-level `toolCallResult`. + +Treat transcript timestamps, model usage (`usageMetadata`), tool calls, and +tool results as provider-labelled coverage. Generated files under +`~/.qwen/insights` are derived output and never become observed Session facts. +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. diff --git a/scripts/agent-customize/cli.mjs b/scripts/agent-customize/cli.mjs index 7478437..c202bc2 100644 --- a/scripts/agent-customize/cli.mjs +++ b/scripts/agent-customize/cli.mjs @@ -29,6 +29,7 @@ function summarize(inventory, options) { qoderHome: inventory.qoderHome, codexHome: inventory.codexHome, claudeHome: inventory.claudeHome, + qwenHome: inventory.qwenHome, claudeStatePath: inventory.claudeStatePath, codexAppPath: inventory.codexAppPath, sharedClientCacheRoot: inventory.sharedClientCacheRoot, @@ -70,6 +71,7 @@ async function main() { qoderHome: options["qoder-home"], codexHome: options["codex-home"], claudeHome: options["claude-home"], + qwenHome: options["qwen-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/index.mjs b/scripts/agent-customize/providers/index.mjs index b3b11b6..768444c 100644 --- a/scripts/agent-customize/providers/index.mjs +++ b/scripts/agent-customize/providers/index.mjs @@ -2,12 +2,14 @@ import { collectClaudeCustomizeInventory } from "./claude.mjs"; import { collectCodexCustomizeInventory } from "./codex.mjs"; import { collectCursorCustomizeInventory } from "./cursor.mjs"; import { collectQoderCustomizeInventory } from "./qoder.mjs"; +import { collectQwenCustomizeInventory } from "./qwen.mjs"; export const PROVIDER_COLLECTORS = new Map([ ["cursor", collectCursorCustomizeInventory], ["qoder", collectQoderCustomizeInventory], ["codex", collectCodexCustomizeInventory], ["claude", collectClaudeCustomizeInventory], + ["qwen", collectQwenCustomizeInventory], ]); export async function collectProviderInventory(provider, options = {}) { diff --git a/scripts/agent-customize/providers/qwen.mjs b/scripts/agent-customize/providers/qwen.mjs new file mode 100644 index 0000000..f427bc5 --- /dev/null +++ b/scripts/agent-customize/providers/qwen.mjs @@ -0,0 +1,266 @@ +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, + collectMcpItems, + collectRuleSources, + collectSkillFiles, + collectWorkspaceRootPrimitives, + designMarkdownRuleSource, + directoryRuleSource, + evidence, + listDirectories, + normalizePluginDisplayName, + pluginMetadataEvidencePath, + readJson, + readText, + sortByName, + titleCase, + workspaceSourceLabel, +} from "../core/items.mjs"; + +const QWEN_PLUGIN_MANIFEST = [".qwen-plugin", "plugin.json"]; +const QWEN_EXTENSION_INSTALL_FILE = ".qwen-extension-install.json"; +const QWEN_EXTENSION_ENABLEMENT_FILE = "extension-enablement.json"; + +function defaultQwenHome() { + return path.join(os.homedir(), ".qwen"); +} + +function qwenMarkdownRuleSource(workspace, sourceLabel, precedence = "after-provider-rules") { + return { + type: "file", + filePath: path.join(workspace, "QWEN.md"), + scope: "project", + sourceLabel, + rootForEvidence: workspace, + name: "QWEN.md", + sourceKind: "qwen-md-context", + precedence, + useHeading: true, + }; +} + +function normalizeProvidedQwenRecord(record) { + const id = String(record?.id ?? record?.name ?? "").trim(); + const installPath = record?.installPath ?? record?.source; + if (!id || !installPath) { + return undefined; + } + return { + id, + name: record.name ?? id, + marketplaceName: record.marketplaceName ?? "qwen", + installPath: path.resolve(expandHome(installPath)), + installMarkerPath: record.installMarkerPath, + version: record.version, + sources: Array.isArray(record.sources) ? record.sources : [record.source ?? "user"], + source: record.source ?? "user", + installMatch: record.installMatch ?? "provided", + type: record.type ?? "link", + enablement: record.enablement ?? null, + }; +} + +async function readQwenInstalledPluginState(options = {}) { + if (Array.isArray(options.qwenInstalledPluginRecords) || Array.isArray(options.installedPluginRecords)) { + const records = (options.qwenInstalledPluginRecords ?? options.installedPluginRecords) + .map(normalizeProvidedQwenRecord) + .filter(Boolean); + return { records, source: "provided", installRecordFiles: [] }; + } + + const qwenHome = path.resolve(expandHome(options.qwenHome ?? options["qwen-home"] ?? defaultQwenHome())); + const extensionsRoot = path.join(qwenHome, "extensions"); + const enablementPath = path.join(extensionsRoot, QWEN_EXTENSION_ENABLEMENT_FILE); + const enablement = (await readJson(enablementPath)) ?? {}; + const records = []; + const installRecordFiles = []; + for (const extensionDir of await listDirectories(extensionsRoot)) { + const name = path.basename(extensionDir); + const installMarkerPath = path.join(extensionDir, QWEN_EXTENSION_INSTALL_FILE); + if (!(await pathExists(installMarkerPath))) { + continue; + } + installRecordFiles.push(installMarkerPath); + const installMarker = (await readJson(installMarkerPath)) ?? {}; + records.push({ + id: `qwen/${name}`, + name, + marketplaceName: "qwen", + installPath: installMarker.source ?? extensionDir, + installMarkerPath, + version: installMarker.version, + sources: ["user"], + source: installMarker.originSource ?? "user", + installMatch: "qwen-extension-install", + type: installMarker.type ?? "link", + enablement: enablement[name] ?? null, + }); + } + return { + records, + source: records.length > 0 ? "qwen-extensions" : "missing", + installRecordFiles, + }; +} + +async function collectQwenPluginMcpItems(pluginRoot, sourceLabel) { + for (const candidate of [path.join(pluginRoot, ".mcp.json"), path.join(pluginRoot, "mcp.json")]) { + if (await pathExists(candidate)) { + return collectMcpFromConfig(candidate, "plugin", sourceLabel, pluginRoot); + } + } + return []; +} + +async function collectQwenPlugin(record) { + const pluginRoot = path.resolve(expandHome(record.installPath)); + if (!(await pathExists(pluginRoot))) { + return null; + } + const metadataEvidencePath = await pluginMetadataEvidencePath(pluginRoot, [QWEN_PLUGIN_MANIFEST, ["package.json"]]); + const manifest = (await readJson(path.join(pluginRoot, ...QWEN_PLUGIN_MANIFEST))) ?? {}; + const packageJson = (await readJson(path.join(pluginRoot, "package.json"))) ?? {}; + const readme = await readText(path.join(pluginRoot, "README.md"), 6000); + const heading = readme.match(/^#\s+(.+)$/mu)?.[1]?.trim(); + const rawDisplayName = + manifest.interface?.displayName || + manifest.displayName || + packageJson.displayName || + heading || + titleCase(manifest.name || packageJson.name || record.name); + const displayName = normalizePluginDisplayName(rawDisplayName, record.name); + const plugin = { + id: record.id, + qwenExtensionId: record.name, + marketplaceName: record.marketplaceName, + rootPath: pluginRoot, + scope: "plugin", + sourceLabel: displayName, + name: manifest.name || packageJson.name || record.name, + displayName, + description: + manifest.interface?.shortDescription || + manifest.description || + packageJson.description || + "", + publisher: { displayName: manifest.author?.name || manifest.interface?.developerName || titleCase(record.marketplaceName) }, + version: record.version || manifest.version || packageJson.version, + installSources: record.sources, + installSource: record.source, + installMatch: record.installMatch, + installType: record.type, + installRecordPath: record.installMarkerPath, + enabled: record.enablement ? record.enablement.disabled !== true : true, + evidence: evidence(metadataEvidencePath, path.dirname(path.dirname(pluginRoot))), + }; + plugin.skills = await collectSkillFiles(path.join(pluginRoot, "skills"), "plugin", displayName, pluginRoot); + plugin.mcpServers = await collectQwenPluginMcpItems(pluginRoot, displayName); + plugin.rules = await collectRuleSources([ + directoryRuleSource(path.join(pluginRoot, "rules"), "plugin", displayName, pluginRoot), + ]); + plugin.commands = await collectMarkdownItems(path.join(pluginRoot, "commands"), "command", "plugin", displayName, pluginRoot); + plugin.subagents = [ + ...(await collectMarkdownItems(path.join(pluginRoot, "agents"), "subagent", "plugin", displayName, pluginRoot)), + ...(await collectMarkdownItems(path.join(pluginRoot, "subagents"), "subagent", "plugin", displayName, pluginRoot)), + ].sort(sortByName); + plugin.hooks = await collectHookItems(pluginRoot, "plugin", displayName, pluginRoot); + return plugin; +} + +async function collectQwenPlugins(records) { + const plugins = []; + for (const record of records) { + const plugin = await collectQwenPlugin(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 collectQwenUserPrimitives(qwenHome) { + const mcpPath = path.join(path.dirname(qwenHome), ".mcp.json"); + const mcps = await pathExists(mcpPath) + ? (await collectMcpFromConfig(mcpPath, "user", "User", qwenHome)) ?? [] + : (await collectMcpItems(qwenHome, "user", "User", qwenHome)) ?? []; + return { + skills: await collectSkillFiles(path.join(qwenHome, "skills"), "user", "User", qwenHome), + subagents: await collectMarkdownItems(path.join(qwenHome, "agents"), "subagent", "user", "User", qwenHome), + rules: await collectRuleSources([directoryRuleSource(path.join(qwenHome, "rules"), "user", "User", qwenHome)]), + commands: await collectMarkdownItems(path.join(qwenHome, "commands"), "command", "user", "User", qwenHome), + hooks: await collectHookItems(qwenHome, "user", "User", qwenHome), + mcps, + }; +} + +async function collectQwenWorkspacePrimitives(workspace) { + const sourceLabel = await workspaceSourceLabel(workspace); + const project = await collectWorkspaceRootPrimitives(path.join(workspace, ".qwen"), sourceLabel, workspace); + return { + ...project, + rules: [ + ...project.rules, + ...(await collectRuleSources([ + qwenMarkdownRuleSource(workspace, sourceLabel), + agentsMarkdownRuleSource(workspace, sourceLabel), + designMarkdownRuleSource(workspace, sourceLabel), + ])), + ], + }; +} + +function emptyPrimitives() { + return { skills: [], subagents: [], rules: [], commands: [], hooks: [], mcps: [] }; +} + +export async function collectQwenCustomizeInventory(options = {}) { + const qwenHome = path.resolve(expandHome(options.qwenHome ?? options["qwen-home"] ?? defaultQwenHome())); + const workspace = normalizeWorkspace(options.workspace ?? process.cwd()); + const includeUserHome = options.includeUserHome !== false; + const installState = includeUserHome + ? await readQwenInstalledPluginState({ ...options, qwenHome }) + : { records: [], source: "not-authorized", installRecordFiles: [] }; + const [plugins, user, project] = await Promise.all([ + includeUserHome ? collectQwenPlugins(installState.records ?? []) : [], + includeUserHome ? collectQwenUserPrimitives(qwenHome) : emptyPrimitives(), + collectQwenWorkspacePrimitives(workspace), + ]); + return { + generatedAt: new Date().toISOString(), + provider: "qwen", + qwenHome, + workspace, + tabs: MANAGE_TABS, + plugins, + manage: buildManageCollections(plugins, user, project), + diagnostics: { + installedPluginState: installState.source, + installedPluginRecordCount: plugins.length, + installedPluginRecordFiles: installState.installRecordFiles ?? [], + remotePluginInstallMarkersRequired: true, + }, + unsupported: [ + "remote marketplace browse ordering", + "remote marketplace display metadata", + "team usage counts", + "cloud MCP authentication state", + "dashboard-only policy state", + ], + }; +} diff --git a/scripts/agent-lint/cli.mjs b/scripts/agent-lint/cli.mjs index 04977d7..4f728b2 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 55b9e15..7aba164 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, and Cursor session evidence.", + summary: "Collect and normalize Qoder, Codex, Claude, Cursor, and Qwen session evidence.", subcommands: [ { name: "sources", diff --git a/scripts/coding-agent-practices/asset-baseline.mjs b/scripts/coding-agent-practices/asset-baseline.mjs index dfc285b..fb117cf 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"]); +const PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen"]); 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.`); + throw new Error(`Unsupported provider: ${provider}. Supported providers: qoder, codex, claude, cursor, qwen.`); } 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] [options] +const USAGE = `Usage: better-harness coding-agent-practices asset-baseline [qoder|codex|claude|cursor|qwen] [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 63e4f4e..8105f94 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] [options] +const USAGE = `Usage: better-harness coding-agent-practices asset-integrity [qoder|codex|claude|cursor|qwen] [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"].includes(provider)) { - throw new Error(`Unsupported provider: ${provider}. Supported providers: qoder, codex, claude, cursor.`); + if (!["qoder", "codex", "claude", "cursor", "qwen"].includes(provider)) { + throw new Error(`Unsupported provider: ${provider}. Supported providers: qoder, codex, claude, cursor, qwen.`); } 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 35a07b3..dcbfc4d 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"].includes(scope.platform)) { + if (!["qoder", "codex", "claude", "cursor", "qwen"].includes(scope.platform)) { return []; } return [ @@ -888,12 +888,12 @@ export function formatInventoryMarkdown(result) { return `${lines.join("\n")}\n`; } -const USAGE = `Usage: better-harness coding-agent-practices inventory [qoder|codex|claude|cursor] [options] +const USAGE = `Usage: better-harness coding-agent-practices inventory [qoder|codex|claude|cursor|qwen] [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 @@ -915,9 +915,9 @@ async function runCli(argv) { } const { command, options } = parseArgs(argv); const platform = options.platform ?? command ?? "qoder"; - if (!["cursor", "qoder", "codex", "claude"].includes(platform)) { + if (!["cursor", "qoder", "codex", "claude", "qwen"].includes(platform)) { throw new Error( - `Unsupported platform: ${platform}. Supported platforms: cursor, qoder, codex, claude.\n\n${USAGE}`, + `Unsupported platform: ${platform}. Supported platforms: cursor, qoder, codex, claude, qwen.\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 2266660..737d91d 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"]); +const ASSET_PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen"]); 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 e94304f..f176cf9 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, or cursor (default: qoder) + --platform qoder, codex, claude, cursor, or qwen (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", "claude-state", "help", "h", + "cursor-home", "qwen-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 e5864d4..1b6438f 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"]); +const PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen"]); 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 4aa6989..8758bef 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|qoder|codex|claude|cursor|session-analysis|session sources|session evidence|会话分析|会话证据)/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_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 f301750..81b5a5c 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, or cursor (default: qoder) + --platform qoder, codex, claude, cursor, or qwen (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"].includes(platform)) { + if (!["qoder", "codex", "claude", "cursor", "qwen"].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", + "qoder-home", "codex-home", "claude-home", "cursor-home", "qwen-home", ]); const positional = Array.isArray(options._) ? options._ : []; const unknown = Object.keys(options).filter((key) => key !== "_" && !allowed.has(key)); @@ -160,6 +160,7 @@ export async function analyzeHarnessEvidence(options = {}) { codexHome: options["codex-home"], claudeHome: options["claude-home"], cursorHome: options["cursor-home"], + qwenHome: options["qwen-home"], })).source; const sourceErrors = validateHarnessReportSource(source); if (sourceErrors.length > 0) { diff --git a/scripts/harness-analysis/task-loop-report.mjs b/scripts/harness-analysis/task-loop-report.mjs index 5419dc9..fcfebc5 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"].includes(source?.manifest?.scope?.platform) + const platform = ["qoder", "codex", "claude", "cursor", "qwen"].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 7fb283a..3a14975 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, or Cursor sessions. It retains privacy-safe episode, change, validation, +Qoder, Codex, Claude, Cursor, or Qwen 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"].includes(provider); + const assetReviewSupported = ["qoder", "codex", "claude", "cursor", "qwen"].includes(provider); const common = { workspace: options.workspace, provider, @@ -821,6 +821,7 @@ export async function collectAgentLintPracticeEvidence(options = {}) { codexHome: options.codexHome ?? options["codex-home"], claudeHome: options.claudeHome ?? options["claude-home"], cursorHome: options.cursorHome ?? options["cursor-home"], + qwenHome: options.qwenHome ?? options["qwen-home"], }; const [instructionReview, assetReview, practiceInventory] = await Promise.all([ runAgentLint({ ...common, profile: "agents-md-review" }), @@ -908,6 +909,16 @@ export function collectTaskLoopPracticeInventory(options = {}, platform = option claudeStatePath: options.claudeStatePath ?? options["claude-state"], }); } + if (platform === "qwen") { + return collectProviderInventory({ + platform, + workspace: options.workspace, + includeUserHome: includeGlobalCapabilities, + includeGlobalHooks: true, + includeMemories: false, + qwenHome: options.qwenHome ?? options["qwen-home"], + }); + } return Promise.resolve(null); } diff --git a/scripts/npm-package/verify-pack.mjs b/scripts/npm-package/verify-pack.mjs index 7968871..1f83d36 100644 --- a/scripts/npm-package/verify-pack.mjs +++ b/scripts/npm-package/verify-pack.mjs @@ -53,6 +53,7 @@ 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], + [".qwen-plugin/plugin.json", readJson(".qwen-plugin/plugin.json").version], ]; for (const [source, version] of versions) { if (version !== packageVersion) { @@ -137,6 +138,7 @@ const required = [ "package/.cursor-plugin/plugin.json", "package/.cursor-plugin/marketplace.json", "package/.qoder-plugin/plugin.json", + "package/.qwen-plugin/plugin.json", "package/case-studies/factory/model/factory-readiness.md", "package/docs/glossary.md", "package/scripts/better-harness.mjs", @@ -315,6 +317,7 @@ const forbiddenBundlePrefixes = [ ".claude-plugin/", ".codex-plugin/", ".cursor-plugin/", + ".qwen-plugin/", "test/", "dev/", ".idea/", diff --git a/scripts/session-analysis.mjs b/scripts/session-analysis.mjs index addf652..51adbbc 100644 --- a/scripts/session-analysis.mjs +++ b/scripts/session-analysis.mjs @@ -224,7 +224,14 @@ async function loadPlatform(platform = "qoder") { main: module.main, }; } - throw new Error(`Unsupported platform: ${platform}. Supported platforms: qoder, codex, claude, cursor.`); + if (platform === "qwen") { + const module = await import("./session-analysis/platforms/qwen.mjs"); + return { + Analyzer: module.QwenSessionAnalyzer, + main: module.main, + }; + } + throw new Error(`Unsupported platform: ${platform}. Supported platforms: qoder, codex, claude, cursor, qwen.`); } export async function createAnalyzer(platform = "qoder") { @@ -274,7 +281,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 1ce0334..79c626f 100644 --- a/scripts/session-analysis/analyzer.mjs +++ b/scripts/session-analysis/analyzer.mjs @@ -19,7 +19,7 @@ import { createCodexCliJsonModelClient } from "./codex-json-model.mjs"; export const SESSION_ANALYSIS_HELP = `Usage: better-harness session-analysis [command] [options] -Inspect local Qoder, Codex, Claude, or Cursor session evidence. The default +Inspect local Qoder, Codex, Claude, Cursor, or Qwen session evidence. The default command is sessions and the default platform is qoder. Help exits before reading HOME or workspace. @@ -35,7 +35,7 @@ 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) @@ -257,7 +257,14 @@ async function loadPlatform(platform = "qoder") { main: module.main, }; } - throw new Error(`Unsupported platform: ${platform}. Supported platforms: qoder, codex, claude, cursor.`); + if (platform === "qwen") { + const module = await import("./platforms/qwen.mjs"); + return { + Analyzer: module.QwenSessionAnalyzer, + main: module.main, + }; + } + throw new Error(`Unsupported platform: ${platform}. Supported platforms: qoder, codex, claude, cursor, qwen.`); } export async function createAnalyzer(platform = "qoder") { @@ -271,7 +278,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 3e66d3a..f627aec 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"].includes(host) ? host : "unknown"; + return ["qoder", "codex", "claude", "cursor", "qwen"].includes(host) ? host : "unknown"; } function safeEvidenceToken(value, fallback) { diff --git a/scripts/session-analysis/platforms/qwen.mjs b/scripts/session-analysis/platforms/qwen.mjs new file mode 100644 index 0000000..bb23252 --- /dev/null +++ b/scripts/session-analysis/platforms/qwen.mjs @@ -0,0 +1,374 @@ +#!/usr/bin/env node + +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { SessionAnalyzer } from "../../session-analysis.mjs"; +import { parseArgs, parseBooleanFlag } from "../cli.mjs"; +import { forEachJsonLine, pathExists, walkFiles } 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"; + +function isWorkspaceMatch(candidate, workspace) { + if (!candidate) return false; + const resolved = normalizeWorkspace(candidate); + return resolved === workspace || resolved.startsWith(`${workspace}${path.sep}`); +} + +export function workspaceToQwenSlugVariants(workspace) { + const expanded = expandHome(workspace ?? process.cwd()); + const normalized = path.win32.isAbsolute(expanded) ? path.win32.normalize(expanded) : normalizeWorkspace(expanded); + return [...new Set([ + normalized.replace(/[:_.]/g, "-").replace(/[\\/]+/g, "-"), + normalized.replace(/[:.]/g, "").replace(/[_.\\/]+/g, "-"), + ])]; +} + +function inferSessionId(raw, fallback = null) { + return raw?.sessionId ?? raw?.session_id ?? fallback; +} + +function inferTimestamp(raw) { + return normalizeTimestamp(raw?.timestamp ?? raw?.ts ?? raw?._timestamp ?? null); +} + +function messageParts(raw) { + const parts = raw?.message?.parts ?? raw?.message?.content ?? raw?.parts; + return Array.isArray(parts) ? parts : []; +} + +function textFromParts(parts) { + return parts + .filter((part) => typeof part === "string" || (part && typeof part === "object" && part.text !== undefined && !part.thought)) + .map((part) => (typeof part === "string" ? part : part.text)) + .filter(Boolean) + .join("\n") + .trim(); +} + +function inferUsage(raw) { + const usage = raw?.usageMetadata ?? raw?.message?.usageMetadata ?? raw?.usage; + if (!usage || typeof usage !== "object") return null; + const fields = { + inputTokens: usage.promptTokenCount ?? usage.input_tokens ?? usage.inputTokens, + outputTokens: usage.candidatesTokenCount ?? usage.output_tokens ?? usage.outputTokens, + cacheReadInputTokens: usage.cachedContentTokenCount ?? usage.cache_read_input_tokens ?? usage.cacheReadInputTokens, + cacheCreationInputTokens: usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens, + }; + return Object.values(fields).some((value) => value !== undefined) + ? Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, Number.isFinite(Number(value)) ? Number(value) : 0])) + : null; +} + +function inferFilePath(toolName, input = {}) { + if (!/(?:read|edit|write|file|notebook)/i.test(String(toolName ?? ""))) return null; + return input.file_path ?? input.filePath ?? input.path ?? null; +} + +function inferCommandText(toolName, input = {}) { + if (!/(?:bash|shell|exec|terminal|run)/i.test(String(toolName ?? ""))) return null; + return input.command ?? input.cmd ?? null; +} + +function evidenceRef(raw, sourceRef, type, itemIndex = null) { + return { + kind: sourceRef.kind, + path: sourceRef.path, + line: sourceRef.line ?? null, + seq: itemIndex, + type, + }; +} + +function transcriptEvents(raw, sourceRef, options) { + const rawType = raw?.type ?? "record"; + const sessionId = inferSessionId(raw, sourceRef.sessionId); + const timestamp = inferTimestamp(raw); + const cwd = raw?.cwd ?? null; + const parts = messageParts(raw); + const events = []; + const base = { + sessionId, + timestamp, + sourceKind: sourceRef.kind, + planningScope: "workspace", + cwd, + isSubagent: null, + }; + + if (rawType === "user") { + const text = textFromParts(parts); + events.push({ + ...base, + type: "user", + category: "user", + evidenceRef: evidenceRef(raw, 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 } : {}), + }); + } else if (rawType === "assistant") { + const model = raw?.model ?? raw?.message?.model; + const usage = inferUsage(raw); + const visibleText = textFromParts(parts); + const event = { + ...base, + type: "assistant", + category: "assistant", + evidenceRef: evidenceRef(raw, sourceRef, "assistant"), + summary: visibleText ? `assistant message (${visibleText.length} chars)` : "assistant", + contentLength: visibleText.length, + }; + if (visibleText) event.userVisibleAssistantMessage = true; + if (options.includeContent && visibleText) event.content = visibleText; + if (model && !usage) event.model = model; + events.push(event); + if (usage) { + events.push({ + ...base, + type: "model.response.completed", + category: "model", + model: model ?? null, + modelUsage: usage, + usageFieldsObserved: true, + responseId: raw?.uuid ?? null, + evidenceRef: evidenceRef(raw, sourceRef, "model.response.completed"), + summary: "Qwen model response completed", + }); + } + parts.forEach((part, index) => { + if (!part || typeof part !== "object" || !part.functionCall) return; + const fc = part.functionCall; + const input = fc.args && typeof fc.args === "object" ? fc.args : {}; + const toolEvent = { + ...base, + type: "tool.call", + category: "tool", + lifecyclePhase: "request", + toolName: fc.name ?? "unknown-tool", + toolInvocationId: fc.id ?? null, + evidenceRef: evidenceRef(raw, sourceRef, "tool.call", index), + summary: `${fc.name ?? "unknown-tool"} request`, + }; + const commandText = inferCommandText(fc.name, input); + const filePath = inferFilePath(fc.name, input); + if (options.includeCommandText && commandText) toolEvent.commandText = commandText; + if (filePath) toolEvent.filePath = filePath; + if (fc.name === "Skill") { + const skillName = input.skill ?? input.name; + if (skillName) { + toolEvent.skillName = String(skillName).split(":").at(-1); + toolEvent.skillNames = [toolEvent.skillName]; + } + } + events.push(toolEvent); + }); + } else if (rawType === "tool_result") { + const tcr = raw?.toolCallResult ?? {}; + const callId = tcr.callId ?? null; + const hasError = Boolean(tcr.errorType) || (tcr.error && Object.keys(tcr.error).length > 0); + const success = !hasError && tcr.status !== "error" && tcr.status !== "failed"; + const output = tcr.resultDisplay ?? ""; + const fr = parts.find((part) => part?.functionResponse)?.functionResponse; + const event = { + ...base, + type: "tool.result", + category: "tool", + lifecyclePhase: "result", + toolInvocationId: callId, + success, + hasError: !success, + evidenceRef: evidenceRef(raw, sourceRef, "tool.result"), + summary: !success ? "tool result failed" : "tool result", + }; + if (fr?.name) event.toolName = fr.name; + const resultFacts = parseResultFacts(String(output).slice(-8_192)); + if (resultFacts) event.resultFacts = resultFacts; + events.push(event); + } else if (rawType === "system") { + events.push({ + ...base, + type: "metadata.system", + category: "metadata", + evidenceRef: evidenceRef(raw, sourceRef, "metadata.system"), + summary: raw?.subtype ? `system:${raw.subtype}` : "system", + }); + } else { + events.push({ + ...base, + type: `metadata.${rawType}`, + category: "metadata", + evidenceRef: evidenceRef(raw, sourceRef, `metadata.${rawType}`), + summary: rawType, + }); + } + + return events; +} + +async function probeTranscript(filePath, workspace) { + const summary = { sessionId: path.basename(filePath, ".jsonl"), firstSeen: null, lastSeen: null, workspaceMatch: false }; + await forEachJsonLine(filePath, (raw) => { + summary.sessionId = inferSessionId(raw, summary.sessionId); + if (isWorkspaceMatch(raw?.cwd, workspace)) summary.workspaceMatch = true; + mergeTimeRange(summary, inferTimestamp(raw)); + }); + return summary; +} + +function addRef(sessions, sessionId, workspace, ref) { + if (!sessionId) return; + const session = sessions.get(sessionId) ?? { + sessionId, + workspace, + firstSeen: null, + lastSeen: null, + sourceKinds: new Set(), + sourceRefs: [], + }; + session.sourceKinds.add(ref.kind); + session.sourceRefs.push(ref); + mergeTimeRange(session, ref.firstSeen ?? ref.timestamp); + mergeTimeRange(session, ref.lastSeen ?? ref.timestamp); + sessions.set(sessionId, session); +} + +function finalizeSession(session) { + return { ...session, sourceKinds: [...session.sourceKinds].sort() }; +} + +function dedupeEvents(events) { + const seen = new Set(); + return events.filter((event) => { + const key = event.toolInvocationId && event.lifecyclePhase + ? `${event.sessionId}:${event.lifecyclePhase}:${event.toolInvocationId}` + : null; + if (!key) return true; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export class QwenSessionAnalyzer extends SessionAnalyzer { + currentSessionId() { + return process.env.QWEN_SESSION_ID ?? process.env.QWENCODE_SESSION_ID ?? null; + } + + async resolveScope(options = {}) { + const since = normalizeCliDate(options.since, false); + const until = normalizeCliDate(options.until, true); + const workspace = normalizeWorkspace(options.workspace); + return { + platform: "qwen", + workspace, + home: path.resolve(expandHome(options.home ?? options.qwenHome ?? options["qwen-home"] ?? "~/.qwen")), + _workspaceSlugVariants: workspaceToQwenSlugVariants(workspace), + 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 projectPaths = scope._workspaceSlugVariants.map((slug) => path.join(scope.home, "projects", slug, "chats")); + const roots = [ + { + id: "qwen-projects", + kind: "qwen-project-jsonl", + role: "session-transcript", + path: projectPaths[0], + paths: projectPaths, + optional: false, + enabled: true, + workspaceScoped: true, + coverage: "primary", + }, + ]; + return Promise.all(roots.map(async (root) => ({ + ...root, + exists: root.paths + ? (await Promise.all(root.paths.map(pathExists))).some(Boolean) + : await pathExists(root.path), + }))); + } + + async discoverSessions(scope, roots) { + const sessions = new Map(); + const transcriptRoot = roots.find((root) => root.kind === "qwen-project-jsonl"); + for (const rootPath of transcriptRoot?.paths ?? []) { + if (!await pathExists(rootPath)) continue; + const files = await walkFiles(rootPath, { maxDepth: 2, limit: 20_000, match: (file) => file.endsWith(".jsonl") }); + for (const filePath of files) { + const probe = await probeTranscript(filePath, scope.workspace); + if (!probe.workspaceMatch || !withinTimeRange(probe.lastSeen ?? probe.firstSeen, scope)) continue; + addRef(sessions, probe.sessionId, scope.workspace, { + kind: transcriptRoot.kind, + role: transcriptRoot.role, + path: filePath, + firstSeen: probe.firstSeen, + lastSeen: probe.lastSeen, + }); + } + } + return [...sessions.values()].map(finalizeSession) + .sort((left, right) => (timestampMillis(right.lastSeen) ?? 0) - (timestampMillis(left.lastSeen) ?? 0)); + } + + 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) => { + if (inferSessionId(raw, session.sessionId) !== session.sessionId) return; + if (raw?.cwd && !isWorkspaceMatch(raw.cwd, scope.workspace)) return; + 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) + || Number(left.evidenceRef?.seq ?? 0) - Number(right.evidenceRef?.seq ?? 0)); + } + + async analyze(options = {}) { + return runProviderAnalysis(this, options, { platform: "qwen", adapterVersion: "qwen-v1" }); + } +} + +export async function main(argv = process.argv.slice(2)) { + const { command = "sessions", options } = parseArgs(argv); + const analyzer = new QwenSessionAnalyzer(); + const result = await runProviderCommand(analyzer, command, options); + await emitProviderResult({ provider: "Qwen", 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(`qwen 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 30601fe..3b62dbb 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/usage-summary.mjs b/scripts/session-analysis/usage-summary.mjs index e83fd39..b1f2aae 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/test/agent-asset-baseline.test.mjs b/test/agent-asset-baseline.test.mjs index 64adcdc..7b785d4 100644 --- a/test/agent-asset-baseline.test.mjs +++ b/test/agent-asset-baseline.test.mjs @@ -257,3 +257,34 @@ test("Claude asset baseline completes from a native project fixture", async () = await rm(root, { recursive: true, force: true }); } }); + +test("Qwen asset baseline completes from a native project fixture", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-asset-baseline-qwen-")); + const workspace = path.join(root, "project"); + const qwenHome = path.join(root, ".qwen-home"); + try { + await mkdir(qwenHome, { recursive: true }); + await mkdir(path.join(workspace, ".qwen", "skills", "review"), { recursive: true }); + await writeFile(path.join(workspace, "QWEN.md"), "# Qwen project\n\nRun npm test.\n"); + await writeFile( + path.join(workspace, ".qwen", "skills", "review", "SKILL.md"), + "---\nname: review\ndescription: Review a bounded Qwen project change.\n---\n", + ); + + const result = await collectAssetBaseline({ + provider: "qwen", + workspace, + qwenHome, + includeUserHome: false, + }); + + assert.equal(result.status, "complete"); + assert.equal(result.scope.provider, "qwen"); + assert.equal(result.envelopes.inventory.status, "available"); + assert.equal(result.envelopes.lint.data.assetInventory.summary.skills, 1); + assert.equal(result.envelopes.inventory.data.ownerRoutes.items.some((item) => + item.kind === "skills" && item.name === "review"), true); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/agent-asset-integrity.test.mjs b/test/agent-asset-integrity.test.mjs index b300e41..f68084b 100644 --- a/test/agent-asset-integrity.test.mjs +++ b/test/agent-asset-integrity.test.mjs @@ -159,6 +159,39 @@ test("public asset-integrity CLI stays read-only and omits Memory body text and } }); +test("public asset-integrity CLI reviews Qwen asset metadata without reading bodies", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-qwen-integrity-")); + const workspace = path.join(root, "workspace"); + const qwenHome = path.join(root, ".qwen"); + try { + await mkdir(workspace, { recursive: true }); + await mkdir(path.join(qwenHome, "skills", "audit"), { recursive: true }); + await writeFile( + path.join(qwenHome, "skills", "audit", "SKILL.md"), + "---\nname: audit\ndescription: Audit assets.\n---\n", + ); + await writeFile(path.join(workspace, "QWEN.md"), "# Qwen project\n"); + const script = path.join(process.cwd(), "scripts", "better-harness.mjs"); + const { stdout } = await execFileAsync(process.execPath, [ + script, + "coding-agent-practices", + "asset-integrity", + "qwen", + "--workspace", + workspace, + "--qwen-home", + qwenHome, + "--include-user-home", + "--json", + ]); + const result = JSON.parse(stdout); + assert.ok(result.summary); + assert.doesNotMatch(stdout, /better-harness-qwen-integrity-/u); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("public asset-integrity CLI reviews Codex Memory metadata without reading bodies", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-codex-integrity-")); const workspace = path.join(root, "workspace"); diff --git a/test/agent-customize-architecture.test.mjs b/test/agent-customize-architecture.test.mjs index b3a046d..071f1bc 100644 --- a/test/agent-customize-architecture.test.mjs +++ b/test/agent-customize-architecture.test.mjs @@ -50,8 +50,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, and Cursor plugin metadata roots/u); - assert.match(adapterReadme, /generated Qoder runtime bundle\s+includes only the Qoder shell/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, /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,12 +67,12 @@ 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 four plugin metadata roots[\s\S]*Qoder runtime bundle\s+includes only `\.qoder-plugin\/`/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, /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 four current metadata roots[\s\S]*Qoder runtime bundle includes only `\.qoder-plugin\/`/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, /owning `models\/\.md`, `scripts\/\/`, or `skills\/\/references\/`/u); - assert.match(glossary, /public npm package ships all four current metadata roots[\s\S]*Qoder runtime bundle includes only `\.qoder-plugin\/`/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, /Start with \[model routing\]\(\.\.\/models\/routing\.md\)/u); assert.match(adapterReadme, /Claude Code\s+\|/u); diff --git a/test/agent-customize.test.mjs b/test/agent-customize.test.mjs index 98bc052..c9c47c2 100644 --- a/test/agent-customize.test.mjs +++ b/test/agent-customize.test.mjs @@ -690,6 +690,120 @@ async function makeClaudeFixture() { return { root, claudeHome, claudeStatePath, workspace, enabledPluginId, disabledPluginId }; } +async function makeQwenFixture() { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-agent-customize-qwen-")); + const qwenHome = path.join(root, ".qwen"); + const workspace = path.join(root, "workspace", "better-harness"); + + const deliveryPluginName = "delivery"; + const deliveryPluginSource = path.join(qwenHome, "extension-store", deliveryPluginName); + await writeJson(path.join(qwenHome, "extensions", deliveryPluginName, ".qwen-extension-install.json"), { + source: deliveryPluginSource, + type: "link", + originSource: "QwenCode", + }); + await writeJson(path.join(deliveryPluginSource, ".qwen-plugin", "plugin.json"), { + name: deliveryPluginName, + version: "1.0.0", + description: "Delivery workflow plugin.", + author: { name: "Qoder" }, + interface: { + displayName: "Delivery", + shortDescription: "Run delivery workflows.", + developerName: "Qoder", + }, + skills: "./skills/", + }); + await writeText( + path.join(deliveryPluginSource, "skills", "ship-release", "SKILL.md"), + "---\nname: ship-release\ndescription: Ship a release.\n---\n", + ); + await writeJson(path.join(deliveryPluginSource, ".mcp.json"), { + mcpServers: { + deliveryMcp: { command: "node", args: ["mcp/server.cjs"] }, + }, + }); + await writeJson(path.join(deliveryPluginSource, "hooks.json"), { + hooks: { + PostToolUse: [ + { + matcher: "Write", + hooks: [{ type: "command", command: "node hooks/audit-delivery.mjs" }], + }, + ], + }, + }); + + const disabledPluginName = "disabled-ext"; + const disabledPluginSource = path.join(qwenHome, "extension-store", disabledPluginName); + await writeJson(path.join(qwenHome, "extensions", disabledPluginName, ".qwen-extension-install.json"), { + source: disabledPluginSource, + type: "link", + originSource: "QwenCode", + }); + await writeJson(path.join(disabledPluginSource, ".qwen-plugin", "plugin.json"), { + name: disabledPluginName, + version: "0.1.0", + description: "Disabled extension.", + author: { name: "Qoder" }, + interface: { displayName: "Disabled Extension", developerName: "Qoder" }, + }); + await writeText( + path.join(disabledPluginSource, "skills", "disabled-skill", "SKILL.md"), + "---\nname: disabled-skill\ndescription: This disabled Skill must not enter public surfaces.\n---\n", + ); + + await writeJson(path.join(qwenHome, "extensions", "extension-enablement.json"), { + [disabledPluginName]: { disabled: true, overrides: [] }, + }); + + await writeText( + path.join(qwenHome, "skills", "local-review", "SKILL.md"), + "---\nname: local-review\ndescription: Review locally.\n---\n", + ); + await writeText(path.join(qwenHome, "agents", "user-reviewer.md"), "---\nname: user-reviewer\ndescription: Review code.\ntools: Read\n---\n"); + await writeText(path.join(qwenHome, "commands", "user-check.md"), "# User Check\n"); + await writeText(path.join(qwenHome, "rules", "user-rule.md"), "# User Rule\n"); + await writeJson(path.join(qwenHome, "hooks.json"), { + hooks: { + UserPromptSubmit: [ + { + hooks: [{ type: "command", command: "~/.qwen/hooks/guard-prompt.sh" }], + }, + ], + }, + }); + await writeJson(path.join(root, ".mcp.json"), { + mcpServers: { + localMcp: { command: "node", args: ["server.mjs"] }, + }, + }); + + await writeText( + path.join(workspace, ".qwen", "skills", "qwen-workflow", "SKILL.md"), + "---\nname: qwen-workflow\ndescription: Qwen workflow.\n---\n", + ); + await writeText(path.join(workspace, ".qwen", "rules", "always.md"), "# Always Qwen\n"); + await writeText(path.join(workspace, "QWEN.md"), "# Qwen Project Instructions\n"); + await writeText(path.join(workspace, "AGENTS.md"), "# Project Agent Rules\n"); + await writeText(path.join(workspace, "DESIGN.md"), "# Product Design Contract\n"); + await writeJson(path.join(workspace, ".qwen", "hooks.json"), { + hooks: { + Stop: [ + { + hooks: [{ type: "command", command: "node hooks/check-stop.mjs" }], + }, + ], + }, + }); + await writeText( + path.join(workspace, ".git", "config"), + "[remote \"origin\"]\n\turl = https://github.com/example/better-harness.git\n", + ); + + return { root, qwenHome, workspace }; +} + test("collectAgentCustomizeInventory returns Cursor-style manage tabs and scoped sources", async () => { const fixture = await makeCursorFixture(); @@ -1410,3 +1524,103 @@ test("agent-customize --help stays help-only through the root facade", () => { assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /Usage: better-harness agent-customize/u); }); + +test("collectAgentCustomizeInventory returns Qwen installed plugins from extension evidence", async () => { + const fixture = await makeQwenFixture(); + + try { + const inventory = await collectAgentCustomizeInventory({ + provider: "qwen", + qwenHome: fixture.qwenHome, + workspace: fixture.workspace, + }); + + assert.equal(inventory.provider, "qwen"); + assert.equal(inventory.qwenHome, fixture.qwenHome); + assert.deepEqual( + inventory.plugins.map((plugin) => plugin.displayName), + ["Delivery", "Disabled Extension"], + ); + + const delivery = inventory.plugins.find((plugin) => plugin.name === "delivery"); + assert.ok(delivery); + assert.equal(delivery.installMatch, "qwen-extension-install"); + assert.equal(delivery.skills[0].name, "ship-release"); + assert.equal(delivery.mcpServers[0].name, "deliveryMcp"); + assert.equal(delivery.hooks[0].command, "node hooks/audit-delivery.mjs"); + assert.equal( + delivery.evidence.path, + path.join(delivery.rootPath, ".qwen-plugin", "plugin.json"), + ); + assert.equal(delivery.enabled, true); + + const disabled = inventory.plugins.find((plugin) => plugin.name === "disabled-ext"); + assert.ok(disabled); + assert.equal(disabled.enabled, false); + assert.deepEqual( + filterManageItems(inventory, { tab: "plugins", scopeKind: "user" }).map( + (item) => item.displayName, + ), + ["Delivery", "Disabled Extension"], + ); + assert.equal(inventory.diagnostics.installedPluginState, "qwen-extensions"); + assert.equal(inventory.diagnostics.remotePluginInstallMarkersRequired, true); + assert.deepEqual(inventory.diagnostics.installedPluginRecordFiles.map((file) => path.basename(file)), [ + ".qwen-extension-install.json", + ".qwen-extension-install.json", + ]); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test("Qwen provider collects user and project MCPs, skills, hooks, and rules", async () => { + const fixture = await makeQwenFixture(); + + try { + const inventory = await collectAgentCustomizeInventory({ + provider: "qwen", + qwenHome: fixture.qwenHome, + workspace: fixture.workspace, + }); + + assert.deepEqual( + filterManageItems(inventory, { tab: "mcps", scopeKind: "user" }).map((item) => item.name).sort(), + ["deliveryMcp", "localMcp"], + ); + assert.ok( + filterManageItems(inventory, { tab: "skills", scopeKind: "user" }) + .some((item) => item.name === "local-review" && item.scope === "user"), + ); + assert.deepEqual( + filterManageItems(inventory, { tab: "skills", scopeKind: "project" }).map((item) => item.name), + ["qwen-workflow"], + ); + assert.deepEqual( + filterManageItems(inventory, { tab: "hooks", scopeKind: "user" }) + .map((item) => item.command) + .sort(), + ["node hooks/audit-delivery.mjs", "~/.qwen/hooks/guard-prompt.sh"], + ); + assert.deepEqual( + filterManageItems(inventory, { tab: "hooks", scopeKind: "project" }).map((item) => item.command), + ["node hooks/check-stop.mjs"], + ); + assert.deepEqual( + filterManageItems(inventory, { tab: "rules", scopeKind: "project" }).map( + (item) => `${item.name}:${item.sourceKind ?? "native"}`, + ).sort(), + ["AGENTS.md:agents-md-compat", "DESIGN.md:design-md-contract", "QWEN.md:qwen-md-context", "always:native"].sort(), + ); + assert.deepEqual( + filterManageItems(inventory, { tab: "commands", scopeKind: "user" }).map((item) => item.name), + ["user-check"], + ); + assert.deepEqual( + filterManageItems(inventory, { tab: "agents", scopeKind: "user" }).map((item) => item.name), + ["user-reviewer"], + ); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); diff --git a/test/better-harness-evidence-bundle.test.mjs b/test/better-harness-evidence-bundle.test.mjs index c966ff7..d49ae14 100644 --- a/test/better-harness-evidence-bundle.test.mjs +++ b/test/better-harness-evidence-bundle.test.mjs @@ -170,3 +170,26 @@ test("Claude agentCustomize lane routes the provider and isolated config paths", assert.equal(received["claude-state"], "/tmp/fixture-claude-state.json"); assert.equal(received["include-user-home"], true); }); + +test("Qwen agentCustomize lane routes the provider and isolated config paths", async () => { + const context = freezeEvidenceBundleContext({ + workspace: ".", + platform: "qwen", + depth: "quick", + "include-user-home": true, + }, NOW); + let received; + const lane = await collectAgentCustomize(context, { + "qwen-home": "/tmp/fixture-qwen-home", + }, { + collectAssetBaseline: async (options) => { + received = options; + return { kind: "agent-asset-baseline", status: "complete" }; + }, + }); + + assert.equal(lane.status, "available"); + assert.equal(received.provider, "qwen"); + assert.equal(received["qwen-home"], "/tmp/fixture-qwen-home"); + assert.equal(received["include-user-home"], true); +}); diff --git a/test/coding-agent-platform-notes.test.mjs b/test/coding-agent-platform-notes.test.mjs index cbc6cac..2228516 100644 --- a/test/coding-agent-platform-notes.test.mjs +++ b/test/coding-agent-platform-notes.test.mjs @@ -42,7 +42,8 @@ test("session diagnostics keeps the shared workflow before platform source roots assertAfter(content, "~/.codex/audit-logs", "## Platform Notes", "Sessions Diagnostics"); assertAfter(content, "~/.claude/projects", "## Platform Notes", "Sessions Diagnostics"); assertAfter(content, "~/.cursor/projects", "## Platform Notes", "Sessions Diagnostics"); - assert.match(content, /Supported platforms: `qoder`, `codex`, `claude`, and `cursor`/); + assertAfter(content, "~/.qwen/projects", "## Platform Notes", "Sessions Diagnostics"); + assert.match(content, /Supported platforms: `qoder`, `codex`, `claude`, `cursor`, and `qwen`/); 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 eb94f33..25e8f1d 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, and Cursor session - evidence + session-analysis Collect and normalize Qoder, Codex, Claude, Cursor, and Qwen + 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 4645f50..3c7952e 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 d1ace9d..6a53a3f 100644 --- a/test/plugin-manifests.test.mjs +++ b/test/plugin-manifests.test.mjs @@ -104,6 +104,7 @@ test("host plugin manifests expose canonical Better Harness resources", () => { const claudeMarketplace = readJson(".claude-plugin/marketplace.json"); const cursor = readJson(".cursor-plugin/plugin.json"); const codex = readJson(".codex-plugin/plugin.json"); + const qwen = readJson(".qwen-plugin/plugin.json"); const cursorMarketplace = readJson(".cursor-plugin/marketplace.json"); const packageJson = readJson("package.json"); const packageLock = readJson("package-lock.json"); @@ -151,6 +152,18 @@ test("host plugin manifests expose canonical Better Harness resources", () => { assert.equal(codex.interface.category, qoder.category); assert.equal(codex.hooks, undefined); assert.equal(codex.license, "MIT"); + + assert.equal(qwen.name, qoder.name); + assert.equal(qwen.version, qoder.version); + assert.equal(qwen.description, qoder.description); + assert.deepEqual(qwen.author, qoder.author); + assert.deepEqual(qwen.keywords, qoder.keywords); + assert.equal(qwen.skills, "./skills/"); + assert.equal(qwen.interface.displayName, qoder.displayName); + assert.equal(qwen.interface.developerName, qoder.author.name); + assert.equal(qwen.interface.category, qoder.category); + assert.equal(qwen.hooks, undefined); + assert.equal(qwen.license, "MIT"); assert.equal(cursor.license, "MIT"); assert.equal(qoder.license, "MIT"); assert.equal(packageJson.license, "MIT"); @@ -161,7 +174,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, qwen]) { assert.equal(manifest.homepage, "https://github.com/QoderAI/better-harness"); assert.equal(manifest.repository, "https://github.com/QoderAI/better-harness"); } @@ -203,6 +216,7 @@ test("npm packaging includes every host manifest while the runtime bundle stays ".codex-plugin/", ".cursor-plugin/", ".qoder-plugin/", + ".qwen-plugin/", "AGENTS.md", "CHANGELOG.md", "CODE_OF_CONDUCT.md", @@ -248,14 +262,17 @@ test("npm packaging includes every host manifest while the runtime bundle stays assert.match(verifyScript, /package\/\.codex-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-plugin\/plugin\.json/u); assert.doesNotMatch(verifyScript, /package\/schemas\/proactive-trigger\.v1\.schema\.json/u); assert.doesNotMatch(verifyScript, /package\/scripts\/proactive\/trigger\.mjs/u); assert.match(verifyScript, /package\/scripts\/review-trigger\/cli\.mjs/u); assert.match(verifyScript, /package\/hooks\/hooks\.json/u); assert.match(verifyScript, /\.qoder-plugin\/plugin\.json/u); + assert.match(verifyScript, /\.qwen-plugin\/plugin\.json/u); assert.match(verifyScript, /"\.claude-plugin\/"/u); assert.match(verifyScript, /"\.codex-plugin\/"/u); assert.match(verifyScript, /"\.cursor-plugin\/"/u); + assert.match(verifyScript, /"\.qwen-plugin\/"/u); assert.match(verifyScript, /package\/scripts\/packaging\//u); assert.match(verifyScript, /"scripts\/packaging\/"/u); assert.doesNotMatch(verifyScript, /schemas\/proactive-event\.v1\.schema\.json/u); diff --git a/test/scripts-refactor-contract.test.mjs b/test/scripts-refactor-contract.test.mjs index e237e54..4eaed8f 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: "c2b6b8fc831753efdc0047baf4a37fc142d247109a3a424975dfd4d1763323b8", + sha256: "6a015f7a50ccf60bcdc02f490e16790f011e84925e12440f4967812aad16e408", }, { label: "OpenCLI schema", args: ["schema"], - sha256: "8410994b9af5db28fb53bf66b2197924907b7baeba7a865277fa0cb5c36a6b89", + sha256: "cc5788593fe6f305db290974d4c808647b190a2850c621433f3269dc416ab179", }, { label: "Harness command description", diff --git a/test/session-analysis-providers.test.mjs b/test/session-analysis-providers.test.mjs index dca66ae..ff72622 100644 --- a/test/session-analysis-providers.test.mjs +++ b/test/session-analysis-providers.test.mjs @@ -14,6 +14,10 @@ import { CursorSessionAnalyzer, workspaceToCursorSlugVariants, } from "../scripts/session-analysis/platforms/cursor.mjs"; +import { + QwenSessionAnalyzer, + workspaceToQwenSlugVariants, +} from "../scripts/session-analysis/platforms/qwen.mjs"; import { measureLongSessionRows } from "../scripts/session-analysis/long-sessions.mjs"; async function fixtureRoot(prefix) { @@ -28,15 +32,19 @@ async function writeJsonl(filePath, rows) { 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 createCapabilityAnalyzer("claude") instanceof ClaudeSessionAnalyzer); assert.ok(await createCapabilityAnalyzer("cursor") instanceof CursorSessionAnalyzer); + assert.ok(await createCapabilityAnalyzer("qwen") instanceof QwenSessionAnalyzer); }); -test("Claude and Cursor workspace slugs cover Unix and Windows layouts", () => { +test("Claude, Cursor, and Qwen workspace slugs cover Unix and Windows layouts", () => { assert.ok(workspaceToClaudeSlugVariants("/workspace/project").includes("-workspace-project")); assert.ok(workspaceToCursorSlugVariants("/workspace/project").includes("workspace-project")); + assert.ok(workspaceToQwenSlugVariants("/workspace/project").includes("-workspace-project")); assert.ok(workspaceToClaudeSlugVariants("C:\\workspace\\project").some((value) => value.includes("C--workspace-project"))); assert.ok(workspaceToCursorSlugVariants("C:\\workspace\\project").some((value) => value.includes("C--workspace-project"))); + assert.ok(workspaceToQwenSlugVariants("C:\\workspace\\project").some((value) => value.includes("C--workspace-project"))); }); test("Claude provider expands nested tool requests and results without using generated facets", async () => { @@ -388,3 +396,93 @@ test("Cursor time filters use metadata before excluding out-of-window transcript assert.equal(facts.sourceCoverage.transcript.outOfWindowSessions, 1); assert.equal(facts.sourceCoverage.transcript.relevantSessions, 0); }); + +test("Qwen provider expands function calls and tool results from parts", async () => { + const root = await fixtureRoot("session-qwen-provider-"); + const home = path.join(root, ".qwen"); + const workspace = path.join(root, "workspace", "project"); + const sessionId = "33333333-3333-4333-8333-333333333333"; + const slug = workspaceToQwenSlugVariants(workspace)[0]; + await writeJsonl(path.join(home, "projects", slug, "chats", `${sessionId}.jsonl`), [ + { + type: "user", + sessionId, + cwd: workspace, + timestamp: "2026-07-20T01:00:00.000Z", + message: { role: "user", parts: [{ text: "Implement the provider and run tests" }] }, + }, + { + type: "assistant", + sessionId, + cwd: workspace, + timestamp: "2026-07-20T01:01:00.000Z", + model: "qwen-fixture", + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 4, totalTokenCount: 14, cachedContentTokenCount: 0 }, + contextWindowSize: 131072, + message: { + role: "model", + parts: [ + { text: "I will inspect and validate it." }, + { functionCall: { id: "tool-1", name: "Bash", args: { command: "npm test" } } }, + { functionCall: { id: "tool-2", name: "Read", args: { file_path: path.join(workspace, "package.json") } } }, + ], + }, + }, + { + type: "tool_result", + sessionId, + cwd: workspace, + timestamp: "2026-07-20T01:02:00.000Z", + message: { role: "tool", parts: [{ functionResponse: { id: "tool-1", name: "Bash", response: { output: "3 tests passed" } } }] }, + toolCallResult: { callId: "tool-1", status: "success", resultDisplay: "3 tests passed" }, + }, + { + type: "tool_result", + sessionId, + cwd: workspace, + timestamp: "2026-07-20T01:03:00.000Z", + message: { role: "tool", parts: [{ functionResponse: { id: "tool-2", name: "Read", response: { error: "not found" } } }] }, + toolCallResult: { callId: "tool-2", status: "error", resultDisplay: "not found", errorType: "FileNotFound" }, + }, + ]); + + const analyzer = new QwenSessionAnalyzer(); + const discovery = await analyzer.analyze({ command: "sources", workspace, home }); + assert.equal(discovery.sessions.length, 1); + assert.deepEqual(discovery.sources.map((source) => source.kind), ["qwen-project-jsonl"]); + const scope = await analyzer.resolveScope({ workspace, home }); + const events = await analyzer.readSession(discovery.sessions[0], scope, { + includeCommandText: true, + includeUserText: true, + includeContent: true, + }); + assert.equal(events.filter((event) => event.type === "tool.call").length, 2); + assert.equal(events.filter((event) => event.type === "tool.result").length, 2); + assert.equal(events.find((event) => event.model === "qwen-fixture")?.modelUsage.inputTokens, 10); + assert.equal(events.find((event) => event.toolInvocationId === "tool-2")?.filePath, path.join(workspace, "package.json")); + assert.equal(events.find((event) => event.toolInvocationId === "tool-2" && event.type === "tool.result")?.success, false); + const insights = await analyzer.analyze({ command: "insights", workspace, home, selection: "all-eligible" }); + assert.equal(insights.insights.keySignals.usageEfficiency.coverage.responseCount, 1); + assert.equal(insights.insights.keySignals.usageEfficiency.tokenTotals.inputTokens, 10); + const facts = await analyzer.analyze({ command: "facts", workspace, home, limit: 1 }); + assert.equal(facts.kind, "session-core-facts"); + assert.equal(facts.scope.platform, "qwen"); + assert.doesNotMatch(JSON.stringify(facts), new RegExp(sessionId, "u")); + assert.doesNotMatch(JSON.stringify(facts), new RegExp(home.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "u")); +}); + +test("Qwen provider rejects a transcript whose embedded cwd belongs to another workspace", async () => { + const root = await fixtureRoot("session-qwen-isolation-"); + const home = path.join(root, ".qwen"); + const workspace = path.join(root, "workspace", "target"); + const slug = workspaceToQwenSlugVariants(workspace)[0]; + await writeJsonl(path.join(home, "projects", slug, "chats", "foreign.jsonl"), [{ + type: "user", + sessionId: "foreign", + cwd: path.join(root, "workspace", "other"), + timestamp: "2026-07-20T01:00:00.000Z", + message: { role: "user", parts: [{ text: "foreign" }] }, + }]); + const result = await new QwenSessionAnalyzer().analyze({ command: "sources", workspace, home }); + assert.equal(result.sessions.length, 0); +}); From 62781e456af149928451f2b072808ae7c149e4bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=B3=E5=B4=87=E5=8A=9B?= Date: Tue, 28 Jul 2026 17:57:11 +0800 Subject: [PATCH 2/5] fix(qwen): add functionResponse fallback for tool_result correlation When toolCallResult.resultDisplay is absent (8.9% of real tool_result rows), fall back to functionResponse.response.output for resultFacts extraction. Also use functionResponse.id as a fallback for callId correlation. Addresses Copilot review comment on PR #6. --- scripts/session-analysis/platforms/qwen.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/session-analysis/platforms/qwen.mjs b/scripts/session-analysis/platforms/qwen.mjs index bb23252..cecd9ab 100644 --- a/scripts/session-analysis/platforms/qwen.mjs +++ b/scripts/session-analysis/platforms/qwen.mjs @@ -173,11 +173,11 @@ function transcriptEvents(raw, sourceRef, options) { }); } else if (rawType === "tool_result") { const tcr = raw?.toolCallResult ?? {}; - const callId = tcr.callId ?? null; + const fr = parts.find((part) => part?.functionResponse)?.functionResponse; + const callId = tcr.callId ?? fr?.id ?? null; const hasError = Boolean(tcr.errorType) || (tcr.error && Object.keys(tcr.error).length > 0); const success = !hasError && tcr.status !== "error" && tcr.status !== "failed"; - const output = tcr.resultDisplay ?? ""; - const fr = parts.find((part) => part?.functionResponse)?.functionResponse; + const output = tcr.resultDisplay ?? fr?.response?.output ?? ""; const event = { ...base, type: "tool.result", From 93af416c241980023106c116173878a92e5a13f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=B3=E5=B4=87=E5=8A=9B?= Date: Wed, 29 Jul 2026 09:59:56 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(qwen):=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20native=20manifest,=20runtime=20dir,=20slug,=20enabl?= =?UTF-8?q?ement,=20settings.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address all 7 findings from phodal's review: P1: Replace .qwen-plugin/plugin.json with native qwen-extension.json at repo root, matching Qwen's EXTENSIONS_CONFIG_FILENAME contract. P1: Separate QWEN_HOME (config) from QWEN_RUNTIME_DIR (session data) in resolveScope; discoverSourceRoots now reads from runtimeDir. P1: Match native sanitizeCwd exactly — replace every non-alphanumeric character with '-', lowercase variant for Windows compatibility. P1: Read qwen-extension.json for extension metadata; compute enablement from extension-enablement.json overrides path rules instead of a nonexistent disabled boolean. P1: Read mcpServers and hooks from user/workspace settings.json in addition to .mcp.json and hooks.json. P2: Treat cancelled tool_result status as non-success. P2: Thread qwenHome through providerScope, collectProviderInventory, and userBase fallback in inventory.mjs. Also: rebase onto current main, resolve test/agent-customize.test.mjs conflict. 840 tests pass; pack:verify passes. --- .qwen-plugin/plugin.json | 44 ------ docs/adapters/README.md | 6 +- docs/community.md | 2 +- docs/glossary.md | 2 +- package.json | 2 +- qwen-extension.json | 8 ++ scripts/agent-customize/providers/qwen.mjs | 141 ++++++++++++++++--- scripts/coding-agent-practices/inventory.mjs | 4 +- scripts/npm-package/verify-pack.mjs | 6 +- scripts/session-analysis/platforms/qwen.mjs | 22 ++- test/agent-customize.test.mjs | 18 +-- test/plugin-manifests.test.mjs | 21 ++- 12 files changed, 168 insertions(+), 108 deletions(-) delete mode 100644 .qwen-plugin/plugin.json create mode 100644 qwen-extension.json diff --git a/.qwen-plugin/plugin.json b/.qwen-plugin/plugin.json deleted file mode 100644 index 432e8a6..0000000 --- a/.qwen-plugin/plugin.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "better-harness", - "version": "0.3.0", - "description": "Build an AI-ready engineering system for safe coding-agent delivery and continuous software improvement.", - "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": [ - "qoder-plugin", - "better-harness", - "ai-delivery", - "continuous-improvement", - "agent-harness", - "change-confidence" - ], - "skills": "./skills/", - "interface": { - "displayName": "Better Harness", - "shortDescription": "Evaluate and improve coding-agent delivery readiness.", - "longDescription": "Use the Better Harness skill to analyze repositories, agent workflows, host assets, guardrails, validation habits, and self-improvement signals for AI delivery readiness.", - "developerName": "Qoder", - "category": "Coding", - "capabilities": [ - "Interactive", - "Read", - "Write" - ], - "defaultPrompt": [ - "Evaluate this repository's AI delivery readiness.", - "Draft a repair plan for a Harness finding.", - "Analyze this project's coding-agent workflow." - ], - "websiteURL": "https://github.com/QoderAI/better-harness", - "privacyPolicyURL": "https://qoder.com/en/privacy-policy", - "termsOfServiceURL": "https://qoder.com/product-service", - "brandColor": "#0F766E", - "screenshots": [] - } -} diff --git a/docs/adapters/README.md b/docs/adapters/README.md index b14ddda..cbc48d5 100644 --- a/docs/adapters/README.md +++ b/docs/adapters/README.md @@ -22,7 +22,7 @@ repository's native marketplace manifest. | Codex | Analysis-capable source-local host | `.codex-plugin/` | `scripts/agent-customize/providers/codex.mjs` | `scripts/session-analysis/platforms/codex.mjs` | self-contained HTML + Markdown | `.codex` + `.agents` + `AGENTS.md` | `harness prepare --platform codex` -> finalize with `html-report` validation | | Qoder | First-class product host | `.qoder-plugin/` | `scripts/agent-customize/providers/qoder.mjs` | `scripts/session-analysis/platforms/qoder.mjs` | `better-harness` | `.qoder/rules` + `AGENTS.md` + output templates | `better-harness harness render --mode qoder-canvas --validate` | | Cursor | Analysis-capable source-local host | `.cursor-plugin/` | `scripts/agent-customize/providers/cursor.mjs` | `scripts/session-analysis/platforms/cursor.mjs` | self-contained HTML + Markdown | `.cursor` + `.codex` compatibility + `AGENTS.md` | `agent --plugin-dir . --mode ask --print` -> Cursor evidence bundle -> validated `html` render | -| Qwen Code | Analysis-capable source-local host | `.qwen-plugin/` | `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 | +| 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 | ## Discovery And Evidence @@ -52,8 +52,8 @@ repository's native marketplace manifest. - Qwen Code configured assets are inventoried through `scripts/agent-customize/providers/qwen.mjs`. Session evidence comes from `scripts/session-analysis/platforms/qwen.mjs`, which reads workspace-matching - JSONL transcripts under `~/.qwen/projects//chats/`. The `.qwen-plugin/` - shell is install/discovery metadata included in the public npm package; it + 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. ## Output Modes diff --git a/docs/community.md b/docs/community.md index 373fc5c..c71051a 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-plugin/`, 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/`, `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 | ## Non-Extension Boundaries diff --git a/docs/glossary.md b/docs/glossary.md index ae585df..78bb8b6 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -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-plugin/`, 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/`, `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) | | 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/package.json b/package.json index 5b18f63..d0e7e1e 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ ".codex-plugin/", ".cursor-plugin/", ".qoder-plugin/", - ".qwen-plugin/", + "qwen-extension.json", "AGENTS.md", "CHANGELOG.md", "CODE_OF_CONDUCT.md", diff --git a/qwen-extension.json b/qwen-extension.json new file mode 100644 index 0000000..5091559 --- /dev/null +++ b/qwen-extension.json @@ -0,0 +1,8 @@ +{ + "name": "better-harness", + "version": "0.3.0", + "displayName": "Better Harness", + "description": "Build an AI-ready engineering system for safe coding-agent delivery and continuous software improvement.", + "contextFileName": "QWEN.md", + "skills": "./skills/" +} \ No newline at end of file diff --git a/scripts/agent-customize/providers/qwen.mjs b/scripts/agent-customize/providers/qwen.mjs index f427bc5..2615e18 100644 --- a/scripts/agent-customize/providers/qwen.mjs +++ b/scripts/agent-customize/providers/qwen.mjs @@ -27,7 +27,7 @@ import { workspaceSourceLabel, } from "../core/items.mjs"; -const QWEN_PLUGIN_MANIFEST = [".qwen-plugin", "plugin.json"]; +const QWEN_EXTENSION_MANIFEST = ["qwen-extension.json"]; const QWEN_EXTENSION_INSTALL_FILE = ".qwen-extension-install.json"; const QWEN_EXTENSION_ENABLEMENT_FILE = "extension-enablement.json"; @@ -35,6 +35,39 @@ function defaultQwenHome() { return path.join(os.homedir(), ".qwen"); } +function ensureLeadingAndTrailingSlash(dirPath) { + let result = dirPath.replace(/\\/g, "/"); + if (result.charAt(0) !== "/") result = "/" + result; + if (result.charAt(result.length - 1) !== "/") result = result + "/"; + return result; +} + +function overrideMatchesPath(rule, checkPath) { + const isDisable = rule.startsWith("!"); + let base = isDisable ? rule.substring(1) : rule; + const includeSubdirs = base.endsWith("*"); + if (includeSubdirs) base = base.substring(0, base.length - 1); + base = ensureLeadingAndTrailingSlash(base); + const glob = `${base}${includeSubdirs ? "*" : ""}`; + const regexString = glob + .replace(/[.+?^${}()|[\]\\]/g, "\\$&") + .replace(/(\/?)\*/g, "($1.*)?"); + return new RegExp(`^${regexString}$`).test(checkPath); +} + +function isExtensionEnabled(enablementConfig, extensionName, workspace) { + const extensionConfig = enablementConfig?.[extensionName]; + let enabled = true; + const allOverrides = extensionConfig?.overrides ?? []; + const lexicalPath = ensureLeadingAndTrailingSlash(workspace); + for (const rule of allOverrides) { + if (overrideMatchesPath(rule, lexicalPath)) { + enabled = !rule.startsWith("!"); + } + } + return enabled; +} + function qwenMarkdownRuleSource(workspace, sourceLabel, precedence = "after-provider-rules") { return { type: "file", @@ -66,7 +99,7 @@ function normalizeProvidedQwenRecord(record) { source: record.source ?? "user", installMatch: record.installMatch ?? "provided", type: record.type ?? "link", - enablement: record.enablement ?? null, + enablementConfig: record.enablementConfig ?? null, }; } @@ -103,7 +136,7 @@ async function readQwenInstalledPluginState(options = {}) { source: installMarker.originSource ?? "user", installMatch: "qwen-extension-install", type: installMarker.type ?? "link", - enablement: enablement[name] ?? null, + enablementConfig: enablement, }); } return { @@ -113,32 +146,45 @@ async function readQwenInstalledPluginState(options = {}) { }; } -async function collectQwenPluginMcpItems(pluginRoot, sourceLabel) { +async function collectQwenPluginMcpItems(pluginRoot, sourceLabel, manifest) { + const items = []; + if (manifest?.mcpServers && typeof manifest.mcpServers === "object") { + for (const [name, config] of Object.entries(manifest.mcpServers)) { + items.push({ + name, + scope: "plugin", + sourceLabel, + command: config.command ?? null, + args: config.args ?? [], + evidence: evidence(path.join(pluginRoot, "qwen-extension.json"), pluginRoot), + }); + } + } for (const candidate of [path.join(pluginRoot, ".mcp.json"), path.join(pluginRoot, "mcp.json")]) { if (await pathExists(candidate)) { - return collectMcpFromConfig(candidate, "plugin", sourceLabel, pluginRoot); + items.push(...(await collectMcpFromConfig(candidate, "plugin", sourceLabel, pluginRoot))); } } - return []; + return items; } -async function collectQwenPlugin(record) { +async function collectQwenPlugin(record, workspace) { const pluginRoot = path.resolve(expandHome(record.installPath)); if (!(await pathExists(pluginRoot))) { return null; } - const metadataEvidencePath = await pluginMetadataEvidencePath(pluginRoot, [QWEN_PLUGIN_MANIFEST, ["package.json"]]); - const manifest = (await readJson(path.join(pluginRoot, ...QWEN_PLUGIN_MANIFEST))) ?? {}; + const metadataEvidencePath = await pluginMetadataEvidencePath(pluginRoot, [QWEN_EXTENSION_MANIFEST, ["package.json"]]); + const manifest = (await readJson(path.join(pluginRoot, ...QWEN_EXTENSION_MANIFEST))) ?? {}; const packageJson = (await readJson(path.join(pluginRoot, "package.json"))) ?? {}; const readme = await readText(path.join(pluginRoot, "README.md"), 6000); const heading = readme.match(/^#\s+(.+)$/mu)?.[1]?.trim(); const rawDisplayName = - manifest.interface?.displayName || manifest.displayName || packageJson.displayName || heading || titleCase(manifest.name || packageJson.name || record.name); const displayName = normalizePluginDisplayName(rawDisplayName, record.name); + const enabled = isExtensionEnabled(record.enablementConfig, record.name, workspace); const plugin = { id: record.id, qwenExtensionId: record.name, @@ -148,23 +194,19 @@ async function collectQwenPlugin(record) { sourceLabel: displayName, name: manifest.name || packageJson.name || record.name, displayName, - description: - manifest.interface?.shortDescription || - manifest.description || - packageJson.description || - "", - publisher: { displayName: manifest.author?.name || manifest.interface?.developerName || titleCase(record.marketplaceName) }, + description: manifest.description || packageJson.description || "", + publisher: { displayName: titleCase(record.marketplaceName) }, version: record.version || manifest.version || packageJson.version, installSources: record.sources, installSource: record.source, installMatch: record.installMatch, installType: record.type, installRecordPath: record.installMarkerPath, - enabled: record.enablement ? record.enablement.disabled !== true : true, + enabled, evidence: evidence(metadataEvidencePath, path.dirname(path.dirname(pluginRoot))), }; plugin.skills = await collectSkillFiles(path.join(pluginRoot, "skills"), "plugin", displayName, pluginRoot); - plugin.mcpServers = await collectQwenPluginMcpItems(pluginRoot, displayName); + plugin.mcpServers = await collectQwenPluginMcpItems(pluginRoot, displayName, manifest); plugin.rules = await collectRuleSources([ directoryRuleSource(path.join(pluginRoot, "rules"), "plugin", displayName, pluginRoot), ]); @@ -177,10 +219,10 @@ async function collectQwenPlugin(record) { return plugin; } -async function collectQwenPlugins(records) { +async function collectQwenPlugins(records, workspace) { const plugins = []; for (const record of records) { - const plugin = await collectQwenPlugin(record); + const plugin = await collectQwenPlugin(record, workspace); if (plugin) { plugins.push(plugin); } @@ -199,12 +241,40 @@ async function collectQwenUserPrimitives(qwenHome) { const mcps = await pathExists(mcpPath) ? (await collectMcpFromConfig(mcpPath, "user", "User", qwenHome)) ?? [] : (await collectMcpItems(qwenHome, "user", "User", qwenHome)) ?? []; + const settings = (await readJson(path.join(qwenHome, "settings.json"))) ?? {}; + if (settings.mcpServers && typeof settings.mcpServers === "object") { + for (const [name, config] of Object.entries(settings.mcpServers)) { + if (!mcps.some((m) => m.name === name)) { + mcps.push({ + name, + scope: "user", + sourceLabel: "User", + command: config.command ?? null, + args: config.args ?? [], + evidence: evidence(path.join(qwenHome, "settings.json"), qwenHome), + }); + } + } + } + const hooks = await collectHookItems(qwenHome, "user", "User", qwenHome); + if (Array.isArray(settings.hooks)) { + for (const hook of settings.hooks) { + if (hook?.command && !hooks.some((h) => h.command === hook.command)) { + hooks.push({ + command: hook.command, + scope: "user", + sourceLabel: "User", + evidence: evidence(path.join(qwenHome, "settings.json"), qwenHome), + }); + } + } + } return { skills: await collectSkillFiles(path.join(qwenHome, "skills"), "user", "User", qwenHome), subagents: await collectMarkdownItems(path.join(qwenHome, "agents"), "subagent", "user", "User", qwenHome), rules: await collectRuleSources([directoryRuleSource(path.join(qwenHome, "rules"), "user", "User", qwenHome)]), commands: await collectMarkdownItems(path.join(qwenHome, "commands"), "command", "user", "User", qwenHome), - hooks: await collectHookItems(qwenHome, "user", "User", qwenHome), + hooks, mcps, }; } @@ -212,6 +282,33 @@ async function collectQwenUserPrimitives(qwenHome) { async function collectQwenWorkspacePrimitives(workspace) { const sourceLabel = await workspaceSourceLabel(workspace); const project = await collectWorkspaceRootPrimitives(path.join(workspace, ".qwen"), sourceLabel, workspace); + const settings = (await readJson(path.join(workspace, ".qwen", "settings.json"))) ?? {}; + if (settings.mcpServers && typeof settings.mcpServers === "object") { + for (const [name, config] of Object.entries(settings.mcpServers)) { + if (!project.mcps.some((m) => m.name === name)) { + project.mcps.push({ + name, + scope: "project", + sourceLabel, + command: config.command ?? null, + args: config.args ?? [], + evidence: evidence(path.join(workspace, ".qwen", "settings.json"), workspace), + }); + } + } + } + if (Array.isArray(settings.hooks)) { + for (const hook of settings.hooks) { + if (hook?.command && !project.hooks.some((h) => h.command === hook.command)) { + project.hooks.push({ + command: hook.command, + scope: "project", + sourceLabel, + evidence: evidence(path.join(workspace, ".qwen", "settings.json"), workspace), + }); + } + } + } return { ...project, rules: [ @@ -237,7 +334,7 @@ export async function collectQwenCustomizeInventory(options = {}) { ? await readQwenInstalledPluginState({ ...options, qwenHome }) : { records: [], source: "not-authorized", installRecordFiles: [] }; const [plugins, user, project] = await Promise.all([ - includeUserHome ? collectQwenPlugins(installState.records ?? []) : [], + includeUserHome ? collectQwenPlugins(installState.records ?? [], workspace) : [], includeUserHome ? collectQwenUserPrimitives(qwenHome) : emptyPrimitives(), collectQwenWorkspacePrimitives(workspace), ]); diff --git a/scripts/coding-agent-practices/inventory.mjs b/scripts/coding-agent-practices/inventory.mjs index dcbfc4d..353ba1e 100644 --- a/scripts/coding-agent-practices/inventory.mjs +++ b/scripts/coding-agent-practices/inventory.mjs @@ -447,6 +447,7 @@ function providerScope(options = {}, platform = options.platform ?? "qoder") { codexAppPath: options.codexAppPath ?? options["codex-app-path"], claudeHome: options.claudeHome ?? options["claude-home"], claudeStatePath: options.claudeStatePath ?? options["claude-state"] ?? options["claude-state-path"], + qwenHome: options.qwenHome ?? options["qwen-home"], }; } @@ -535,7 +536,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; + const userBase = inventory.cursorHome ?? inventory.qoderHome ?? inventory.codexHome ?? inventory.claudeHome ?? inventory.qwenHome; const surfaceTypes = [ ["skills", "skills", "Skills"], ["subagents", "agents", "Agents"], @@ -637,6 +638,7 @@ export async function collectProviderInventory(options = {}) { codexAppPath: scope.codexAppPath, claudeHome: scope.claudeHome, claudeStatePath: scope.claudeStatePath, + qwenHome: scope.qwenHome, includeUserHome: scope.includeUserHome, includeGlobalHooks: scope.includeGlobalHooks, }); diff --git a/scripts/npm-package/verify-pack.mjs b/scripts/npm-package/verify-pack.mjs index 1f83d36..c5229ef 100644 --- a/scripts/npm-package/verify-pack.mjs +++ b/scripts/npm-package/verify-pack.mjs @@ -53,7 +53,7 @@ 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], - [".qwen-plugin/plugin.json", readJson(".qwen-plugin/plugin.json").version], + ["qwen-extension.json", readJson("qwen-extension.json").version], ]; for (const [source, version] of versions) { if (version !== packageVersion) { @@ -138,7 +138,7 @@ const required = [ "package/.cursor-plugin/plugin.json", "package/.cursor-plugin/marketplace.json", "package/.qoder-plugin/plugin.json", - "package/.qwen-plugin/plugin.json", + "package/qwen-extension.json", "package/case-studies/factory/model/factory-readiness.md", "package/docs/glossary.md", "package/scripts/better-harness.mjs", @@ -317,7 +317,7 @@ const forbiddenBundlePrefixes = [ ".claude-plugin/", ".codex-plugin/", ".cursor-plugin/", - ".qwen-plugin/", + "qwen-extension.json", "test/", "dev/", ".idea/", diff --git a/scripts/session-analysis/platforms/qwen.mjs b/scripts/session-analysis/platforms/qwen.mjs index cecd9ab..831d7f6 100644 --- a/scripts/session-analysis/platforms/qwen.mjs +++ b/scripts/session-analysis/platforms/qwen.mjs @@ -24,10 +24,11 @@ function isWorkspaceMatch(candidate, workspace) { export function workspaceToQwenSlugVariants(workspace) { const expanded = expandHome(workspace ?? process.cwd()); const normalized = path.win32.isAbsolute(expanded) ? path.win32.normalize(expanded) : normalizeWorkspace(expanded); - return [...new Set([ - normalized.replace(/[:_.]/g, "-").replace(/[\\/]+/g, "-"), - normalized.replace(/[:.]/g, "").replace(/[_.\\/]+/g, "-"), - ])]; + // Match Qwen's sanitizeCwd: replace every non-alphanumeric char with "-". + // On Windows Qwen lowercases first; emit both variants for cross-platform discovery. + const slug = normalized.replace(/[^a-zA-Z0-9]/g, "-"); + const slugLower = slug.toLowerCase(); + return [...new Set([slug, slugLower])]; } function inferSessionId(raw, fallback = null) { @@ -176,7 +177,7 @@ function transcriptEvents(raw, sourceRef, options) { const fr = parts.find((part) => part?.functionResponse)?.functionResponse; const callId = tcr.callId ?? fr?.id ?? null; const hasError = Boolean(tcr.errorType) || (tcr.error && Object.keys(tcr.error).length > 0); - const success = !hasError && tcr.status !== "error" && tcr.status !== "failed"; + const success = !hasError && tcr.status !== "error" && tcr.status !== "failed" && tcr.status !== "cancelled"; const output = tcr.resultDisplay ?? fr?.response?.output ?? ""; const event = { ...base, @@ -267,10 +268,17 @@ export class QwenSessionAnalyzer extends SessionAnalyzer { 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.qwenHome ?? options["qwen-home"] ?? "~/.qwen")); + // Qwen separates config home (QWEN_HOME / ~/.qwen) from runtime data + // (QWEN_RUNTIME_DIR). Session transcripts live under the runtime dir. + const runtimeDir = path.resolve(expandHome( + options.runtimeDir ?? options["runtime-dir"] ?? process.env.QWEN_RUNTIME_DIR ?? home, + )); return { platform: "qwen", workspace, - home: path.resolve(expandHome(options.home ?? options.qwenHome ?? options["qwen-home"] ?? "~/.qwen")), + home, + runtimeDir, _workspaceSlugVariants: workspaceToQwenSlugVariants(workspace), since: since.label, sinceTime: since.time, @@ -282,7 +290,7 @@ export class QwenSessionAnalyzer extends SessionAnalyzer { } async discoverSourceRoots(scope) { - const projectPaths = scope._workspaceSlugVariants.map((slug) => path.join(scope.home, "projects", slug, "chats")); + const projectPaths = scope._workspaceSlugVariants.map((slug) => path.join(scope.runtimeDir, "projects", slug, "chats")); const roots = [ { id: "qwen-projects", diff --git a/test/agent-customize.test.mjs b/test/agent-customize.test.mjs index c9c47c2..8e9a9d5 100644 --- a/test/agent-customize.test.mjs +++ b/test/agent-customize.test.mjs @@ -702,16 +702,11 @@ async function makeQwenFixture() { type: "link", originSource: "QwenCode", }); - await writeJson(path.join(deliveryPluginSource, ".qwen-plugin", "plugin.json"), { + await writeJson(path.join(deliveryPluginSource, "qwen-extension.json"), { name: deliveryPluginName, version: "1.0.0", + displayName: "Delivery", description: "Delivery workflow plugin.", - author: { name: "Qoder" }, - interface: { - displayName: "Delivery", - shortDescription: "Run delivery workflows.", - developerName: "Qoder", - }, skills: "./skills/", }); await writeText( @@ -741,12 +736,11 @@ async function makeQwenFixture() { type: "link", originSource: "QwenCode", }); - await writeJson(path.join(disabledPluginSource, ".qwen-plugin", "plugin.json"), { + await writeJson(path.join(disabledPluginSource, "qwen-extension.json"), { name: disabledPluginName, version: "0.1.0", + displayName: "Disabled Extension", description: "Disabled extension.", - author: { name: "Qoder" }, - interface: { displayName: "Disabled Extension", developerName: "Qoder" }, }); await writeText( path.join(disabledPluginSource, "skills", "disabled-skill", "SKILL.md"), @@ -754,7 +748,7 @@ async function makeQwenFixture() { ); await writeJson(path.join(qwenHome, "extensions", "extension-enablement.json"), { - [disabledPluginName]: { disabled: true, overrides: [] }, + [disabledPluginName]: { overrides: ["!/*"] }, }); await writeText( @@ -1550,7 +1544,7 @@ test("collectAgentCustomizeInventory returns Qwen installed plugins from extensi assert.equal(delivery.hooks[0].command, "node hooks/audit-delivery.mjs"); assert.equal( delivery.evidence.path, - path.join(delivery.rootPath, ".qwen-plugin", "plugin.json"), + path.join(delivery.rootPath, "qwen-extension.json"), ); assert.equal(delivery.enabled, true); diff --git a/test/plugin-manifests.test.mjs b/test/plugin-manifests.test.mjs index 6a53a3f..57c4328 100644 --- a/test/plugin-manifests.test.mjs +++ b/test/plugin-manifests.test.mjs @@ -104,7 +104,7 @@ test("host plugin manifests expose canonical Better Harness resources", () => { const claudeMarketplace = readJson(".claude-plugin/marketplace.json"); const cursor = readJson(".cursor-plugin/plugin.json"); const codex = readJson(".codex-plugin/plugin.json"); - const qwen = readJson(".qwen-plugin/plugin.json"); + const qwen = readJson("qwen-extension.json"); const cursorMarketplace = readJson(".cursor-plugin/marketplace.json"); const packageJson = readJson("package.json"); const packageLock = readJson("package-lock.json"); @@ -156,14 +156,9 @@ test("host plugin manifests expose canonical Better Harness resources", () => { assert.equal(qwen.name, qoder.name); assert.equal(qwen.version, qoder.version); assert.equal(qwen.description, qoder.description); - assert.deepEqual(qwen.author, qoder.author); - assert.deepEqual(qwen.keywords, qoder.keywords); + assert.equal(qwen.displayName, qoder.displayName); + assert.equal(qwen.contextFileName, "QWEN.md"); assert.equal(qwen.skills, "./skills/"); - assert.equal(qwen.interface.displayName, qoder.displayName); - assert.equal(qwen.interface.developerName, qoder.author.name); - assert.equal(qwen.interface.category, qoder.category); - assert.equal(qwen.hooks, undefined); - assert.equal(qwen.license, "MIT"); assert.equal(cursor.license, "MIT"); assert.equal(qoder.license, "MIT"); assert.equal(packageJson.license, "MIT"); @@ -174,7 +169,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, qwen]) { + for (const manifest of [qoder, claude, cursor, codex]) { assert.equal(manifest.homepage, "https://github.com/QoderAI/better-harness"); assert.equal(manifest.repository, "https://github.com/QoderAI/better-harness"); } @@ -216,7 +211,7 @@ test("npm packaging includes every host manifest while the runtime bundle stays ".codex-plugin/", ".cursor-plugin/", ".qoder-plugin/", - ".qwen-plugin/", + "qwen-extension.json", "AGENTS.md", "CHANGELOG.md", "CODE_OF_CONDUCT.md", @@ -262,17 +257,17 @@ test("npm packaging includes every host manifest while the runtime bundle stays assert.match(verifyScript, /package\/\.codex-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-plugin\/plugin\.json/u); + assert.match(verifyScript, /package\/qwen-extension\.json/u); assert.doesNotMatch(verifyScript, /package\/schemas\/proactive-trigger\.v1\.schema\.json/u); assert.doesNotMatch(verifyScript, /package\/scripts\/proactive\/trigger\.mjs/u); assert.match(verifyScript, /package\/scripts\/review-trigger\/cli\.mjs/u); assert.match(verifyScript, /package\/hooks\/hooks\.json/u); assert.match(verifyScript, /\.qoder-plugin\/plugin\.json/u); - assert.match(verifyScript, /\.qwen-plugin\/plugin\.json/u); + assert.match(verifyScript, /qwen-extension\.json/u); assert.match(verifyScript, /"\.claude-plugin\/"/u); assert.match(verifyScript, /"\.codex-plugin\/"/u); assert.match(verifyScript, /"\.cursor-plugin\/"/u); - assert.match(verifyScript, /"\.qwen-plugin\/"/u); + assert.match(verifyScript, /"qwen-extension\.json"/u); assert.match(verifyScript, /package\/scripts\/packaging\//u); assert.match(verifyScript, /"scripts\/packaging\/"/u); assert.doesNotMatch(verifyScript, /schemas\/proactive-event\.v1\.schema\.json/u); From 038be3a89928b2566d369929fe5755742e0ef320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=B3=E5=B4=87=E5=8A=9B?= Date: Wed, 29 Jul 2026 13:18:40 +0800 Subject: [PATCH 4/5] fix(qwen): dedupe slug variant root paths on case-insensitive filesystems On macOS (case-insensitive HFS+), two slug variants differing only in case resolve to the same projects directory, causing session files to be read twice and inflating responseCount. Use realpathSync.native to dedupe resolved root paths before walking. --- scripts/session-analysis/platforms/qwen.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/session-analysis/platforms/qwen.mjs b/scripts/session-analysis/platforms/qwen.mjs index 831d7f6..d56f9b4 100644 --- a/scripts/session-analysis/platforms/qwen.mjs +++ b/scripts/session-analysis/platforms/qwen.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { realpathSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -315,8 +316,13 @@ export class QwenSessionAnalyzer extends SessionAnalyzer { async discoverSessions(scope, roots) { const sessions = new Map(); const transcriptRoot = roots.find((root) => root.kind === "qwen-project-jsonl"); + const seenRoots = new Set(); for (const rootPath of transcriptRoot?.paths ?? []) { if (!await pathExists(rootPath)) continue; + let realRoot; + try { realRoot = realpathSync.native(rootPath); } catch { realRoot = path.resolve(rootPath); } + if (seenRoots.has(realRoot)) continue; + seenRoots.add(realRoot); const files = await walkFiles(rootPath, { maxDepth: 2, limit: 20_000, match: (file) => file.endsWith(".jsonl") }); for (const filePath of files) { const probe = await probeTranscript(filePath, scope.workspace); From 5af2afbfee550216fc66c578148e62966e979bce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=B3=E5=B4=87=E5=8A=9B?= Date: Wed, 29 Jul 2026 13:38:15 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(qwen):=20address=20follow-up=20review?= =?UTF-8?q?=20=E2=80=94=20QWEN=5FHOME=20env,=20native=20hooks,=20MCP=20pat?= =?UTF-8?q?hs,=20canonical=20enablement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. [P1] Read process.env.QWEN_HOME as fallback in both session analyzer resolveScope and customize provider defaultQwenHome. 2. [P1] Parse settings.json hooks in native Qwen shape: object keyed by event name → array of definitions → nested hooks array. Supports both command and http hook types. 3. [P1] User MCP now reads from /settings.json mcpServers (not ~/.mcp.json). Project MCP reads from /.mcp.json (not /.qwen/.mcp.json). Preserve url/httpUrl transport metadata. 4. [P2] Extension enablement now evaluates overrides against both lexical and canonical (realpathSync.native) workspace paths, matching native Qwen behavior for symlinked workspaces. Also: agent-customize --help adds qwen/--qwen-home; reference doc updated for native MCP paths and sanitizeCwd slug algorithm; rebased onto current main. 866 tests pass; pack:verify passes. --- references/agent-customize/platforms/qwen.md | 10 +- scripts/agent-customize/cli.mjs | 4 +- scripts/agent-customize/providers/qwen.mjs | 109 +++++++++++-------- scripts/session-analysis/platforms/qwen.mjs | 2 +- test/agent-customize.test.mjs | 12 +- 5 files changed, 83 insertions(+), 54 deletions(-) diff --git a/references/agent-customize/platforms/qwen.md b/references/agent-customize/platforms/qwen.md index 3bcaff9..b4b0e5d 100644 --- a/references/agent-customize/platforms/qwen.md +++ b/references/agent-customize/platforms/qwen.md @@ -65,14 +65,18 @@ pointer to the real plugin root. ## External Context Use MCP when Qwen Code needs context or actions outside the repository. MCP -configuration lives in `~/.mcp.json` (user) or `/.mcp.json` (project). -Start with one or two MCP tools that remove a real repeated manual step. +servers are configured in `~/.qwen/settings.json` under `mcpServers` (user) or +`/.mcp.json` (project). Project-level `.qwen/settings.json` can also +carry `mcpServers`. Start with one or two MCP tools that remove a real repeated +manual step. ## Session Controls Keep one Qwen Code session per coherent unit of work. Session transcripts are recorded as JSONL under `~/.qwen/projects//chats/`. The slug -replaces path separators (and `.`/`_`) with `-`. Use worktrees when concurrent +replaces every non-alphanumeric character with `-` (matching Qwen's native +`sanitizeCwd`; on Windows the path is lowercased first). Use worktrees when +concurrent sessions could edit the same files. Use subagents for bounded exploration, testing, or independent review. diff --git a/scripts/agent-customize/cli.mjs b/scripts/agent-customize/cli.mjs index c202bc2..63b9ada 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,", - "--claude-state, --codex-app-path, --qoder-shared-client-cache-root.", + "--qwen-home, --claude-state, --codex-app-path, --qoder-shared-client-cache-root.", "", ].join("\n"); } diff --git a/scripts/agent-customize/providers/qwen.mjs b/scripts/agent-customize/providers/qwen.mjs index 2615e18..18ee772 100644 --- a/scripts/agent-customize/providers/qwen.mjs +++ b/scripts/agent-customize/providers/qwen.mjs @@ -1,3 +1,4 @@ +import { realpathSync } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -32,7 +33,7 @@ const QWEN_EXTENSION_INSTALL_FILE = ".qwen-extension-install.json"; const QWEN_EXTENSION_ENABLEMENT_FILE = "extension-enablement.json"; function defaultQwenHome() { - return path.join(os.homedir(), ".qwen"); + return process.env.QWEN_HOME ?? path.join(os.homedir(), ".qwen"); } function ensureLeadingAndTrailingSlash(dirPath) { @@ -60,14 +61,44 @@ function isExtensionEnabled(enablementConfig, extensionName, workspace) { let enabled = true; const allOverrides = extensionConfig?.overrides ?? []; const lexicalPath = ensureLeadingAndTrailingSlash(workspace); + let canonicalPath = lexicalPath; + try { canonicalPath = ensureLeadingAndTrailingSlash(realpathSync.native(path.resolve(workspace))); } catch {} for (const rule of allOverrides) { - if (overrideMatchesPath(rule, lexicalPath)) { + if (overrideMatchesPath(rule, lexicalPath) || overrideMatchesPath(rule, canonicalPath)) { enabled = !rule.startsWith("!"); } } return enabled; } +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 def of definitions) { + if (!Array.isArray(def?.hooks)) continue; + for (const hook of def.hooks) { + if (hook?.command) items.push({ command: hook.command, type: hook.type ?? "command" }); + else if (hook?.url) items.push({ command: hook.url, type: "http" }); + } + } + } + return items; +} + +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 qwenMarkdownRuleSource(workspace, sourceLabel, precedence = "after-provider-rules") { return { type: "file", @@ -237,36 +268,23 @@ async function collectQwenPlugins(records, workspace) { } async function collectQwenUserPrimitives(qwenHome) { - const mcpPath = path.join(path.dirname(qwenHome), ".mcp.json"); - const mcps = await pathExists(mcpPath) - ? (await collectMcpFromConfig(mcpPath, "user", "User", qwenHome)) ?? [] - : (await collectMcpItems(qwenHome, "user", "User", qwenHome)) ?? []; - const settings = (await readJson(path.join(qwenHome, "settings.json"))) ?? {}; + const settingsPath = path.join(qwenHome, "settings.json"); + const settings = (await readJson(settingsPath)) ?? {}; + const mcps = []; if (settings.mcpServers && typeof settings.mcpServers === "object") { for (const [name, config] of Object.entries(settings.mcpServers)) { - if (!mcps.some((m) => m.name === name)) { - mcps.push({ - name, - scope: "user", - sourceLabel: "User", - command: config.command ?? null, - args: config.args ?? [], - evidence: evidence(path.join(qwenHome, "settings.json"), qwenHome), - }); - } + mcps.push(normalizeSettingsMcp(name, config, "user", "User", settingsPath, qwenHome)); } } const hooks = await collectHookItems(qwenHome, "user", "User", qwenHome); - if (Array.isArray(settings.hooks)) { - for (const hook of settings.hooks) { - if (hook?.command && !hooks.some((h) => h.command === hook.command)) { - hooks.push({ - command: hook.command, - scope: "user", - sourceLabel: "User", - evidence: evidence(path.join(qwenHome, "settings.json"), qwenHome), - }); - } + for (const hook of flattenSettingsHooks(settings.hooks)) { + if (!hooks.some((h) => h.command === hook.command)) { + hooks.push({ + command: hook.command, + scope: "user", + sourceLabel: "User", + evidence: evidence(settingsPath, qwenHome), + }); } } return { @@ -282,31 +300,30 @@ async function collectQwenUserPrimitives(qwenHome) { async function collectQwenWorkspacePrimitives(workspace) { const sourceLabel = await workspaceSourceLabel(workspace); const project = await collectWorkspaceRootPrimitives(path.join(workspace, ".qwen"), sourceLabel, workspace); - const settings = (await readJson(path.join(workspace, ".qwen", "settings.json"))) ?? {}; + const projectMcpPath = path.join(workspace, ".mcp.json"); + if (await pathExists(projectMcpPath)) { + const projectMcps = (await collectMcpFromConfig(projectMcpPath, "project", sourceLabel, workspace)) ?? []; + for (const mcp of projectMcps) { + if (!project.mcps.some((m) => m.name === mcp.name)) project.mcps.push(mcp); + } + } + const settingsPath = path.join(workspace, ".qwen", "settings.json"); + const settings = (await readJson(settingsPath)) ?? {}; if (settings.mcpServers && typeof settings.mcpServers === "object") { for (const [name, config] of Object.entries(settings.mcpServers)) { if (!project.mcps.some((m) => m.name === name)) { - project.mcps.push({ - name, - scope: "project", - sourceLabel, - command: config.command ?? null, - args: config.args ?? [], - evidence: evidence(path.join(workspace, ".qwen", "settings.json"), workspace), - }); + project.mcps.push(normalizeSettingsMcp(name, config, "project", sourceLabel, settingsPath, workspace)); } } } - if (Array.isArray(settings.hooks)) { - for (const hook of settings.hooks) { - if (hook?.command && !project.hooks.some((h) => h.command === hook.command)) { - project.hooks.push({ - command: hook.command, - scope: "project", - sourceLabel, - evidence: evidence(path.join(workspace, ".qwen", "settings.json"), workspace), - }); - } + for (const hook of flattenSettingsHooks(settings.hooks)) { + if (!project.hooks.some((h) => h.command === hook.command)) { + project.hooks.push({ + command: hook.command, + scope: "project", + sourceLabel, + evidence: evidence(settingsPath, workspace), + }); } } return { diff --git a/scripts/session-analysis/platforms/qwen.mjs b/scripts/session-analysis/platforms/qwen.mjs index d56f9b4..da6b50a 100644 --- a/scripts/session-analysis/platforms/qwen.mjs +++ b/scripts/session-analysis/platforms/qwen.mjs @@ -269,7 +269,7 @@ export class QwenSessionAnalyzer extends SessionAnalyzer { 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.qwenHome ?? options["qwen-home"] ?? "~/.qwen")); + const home = path.resolve(expandHome(options.home ?? options.qwenHome ?? options["qwen-home"] ?? process.env.QWEN_HOME ?? "~/.qwen")); // Qwen separates config home (QWEN_HOME / ~/.qwen) from runtime data // (QWEN_RUNTIME_DIR). Session transcripts live under the runtime dir. const runtimeDir = path.resolve(expandHome( diff --git a/test/agent-customize.test.mjs b/test/agent-customize.test.mjs index 8e9a9d5..f2d8b29 100644 --- a/test/agent-customize.test.mjs +++ b/test/agent-customize.test.mjs @@ -767,10 +767,18 @@ async function makeQwenFixture() { ], }, }); - await writeJson(path.join(root, ".mcp.json"), { + await writeJson(path.join(qwenHome, "settings.json"), { mcpServers: { localMcp: { command: "node", args: ["server.mjs"] }, }, + hooks: { + PreToolUse: [ + { + matcher: "^Bash$", + hooks: [{ type: "command", command: "~/.qwen/hooks/guard-bash.sh" }], + }, + ], + }, }); await writeText( @@ -1594,7 +1602,7 @@ test("Qwen provider collects user and project MCPs, skills, hooks, and rules", a filterManageItems(inventory, { tab: "hooks", scopeKind: "user" }) .map((item) => item.command) .sort(), - ["node hooks/audit-delivery.mjs", "~/.qwen/hooks/guard-prompt.sh"], + ["node hooks/audit-delivery.mjs", "~/.qwen/hooks/guard-bash.sh", "~/.qwen/hooks/guard-prompt.sh"], ); assert.deepEqual( filterManageItems(inventory, { tab: "hooks", scopeKind: "project" }).map((item) => item.command),