Skip to content

Commit 93af416

Browse files
committed
fix(qwen): address review findings — native manifest, runtime dir, slug, enablement, settings.json
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.
1 parent 62781e4 commit 93af416

12 files changed

Lines changed: 168 additions & 108 deletions

File tree

.qwen-plugin/plugin.json

Lines changed: 0 additions & 44 deletions
This file was deleted.

docs/adapters/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ repository's native marketplace manifest.
2222
| 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 |
2323
| 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` |
2424
| 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 |
25-
| 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 |
25+
| 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 |
2626

2727
## Discovery And Evidence
2828

@@ -52,8 +52,8 @@ repository's native marketplace manifest.
5252
- Qwen Code configured assets are inventoried through
5353
`scripts/agent-customize/providers/qwen.mjs`. Session evidence comes from
5454
`scripts/session-analysis/platforms/qwen.mjs`, which reads workspace-matching
55-
JSONL transcripts under `~/.qwen/projects/<slug>/chats/`. The `.qwen-plugin/`
56-
shell is install/discovery metadata included in the public npm package; it
55+
JSONL transcripts under `~/.qwen/projects/<slug>/chats/`. The `qwen-extension.json`
56+
manifest is native Qwen install/discovery metadata included in the public npm package; it
5757
does not own Qwen evidence collection.
5858

5959
## Output Modes

docs/community.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ This is the complete reference. For the common cases, see Start Here above.
3232
| 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 |
3333
| 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 |
3434
| 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 |
35-
| 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 |
35+
| 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 |
3636

3737
## Non-Extension Boundaries
3838

docs/glossary.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ for extension surfaces, read [community.md](community.md).
7979
|---|---|---|
8080
| 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) |
8181
| Host adapter | Per-host discovery and evidence-shape glue (e.g. Qoder, Codex); keeps the engine host-neutral. | [adapters/README.md](adapters/README.md) |
82-
| 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) |
82+
| 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) |
8383
| Canonical owner | The single directory that owns a behavior's product judgment; host shells and mirrors point back to it. | [ARCHITECTURE.md](ARCHITECTURE.md) |
8484

8585
## "I Want To… → Use"

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
".codex-plugin/",
3131
".cursor-plugin/",
3232
".qoder-plugin/",
33-
".qwen-plugin/",
33+
"qwen-extension.json",
3434
"AGENTS.md",
3535
"CHANGELOG.md",
3636
"CODE_OF_CONDUCT.md",

qwen-extension.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "better-harness",
3+
"version": "0.3.0",
4+
"displayName": "Better Harness",
5+
"description": "Build an AI-ready engineering system for safe coding-agent delivery and continuous software improvement.",
6+
"contextFileName": "QWEN.md",
7+
"skills": "./skills/"
8+
}

scripts/agent-customize/providers/qwen.mjs

Lines changed: 119 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,47 @@ import {
2727
workspaceSourceLabel,
2828
} from "../core/items.mjs";
2929

30-
const QWEN_PLUGIN_MANIFEST = [".qwen-plugin", "plugin.json"];
30+
const QWEN_EXTENSION_MANIFEST = ["qwen-extension.json"];
3131
const QWEN_EXTENSION_INSTALL_FILE = ".qwen-extension-install.json";
3232
const QWEN_EXTENSION_ENABLEMENT_FILE = "extension-enablement.json";
3333

3434
function defaultQwenHome() {
3535
return path.join(os.homedir(), ".qwen");
3636
}
3737

38+
function ensureLeadingAndTrailingSlash(dirPath) {
39+
let result = dirPath.replace(/\\/g, "/");
40+
if (result.charAt(0) !== "/") result = "/" + result;
41+
if (result.charAt(result.length - 1) !== "/") result = result + "/";
42+
return result;
43+
}
44+
45+
function overrideMatchesPath(rule, checkPath) {
46+
const isDisable = rule.startsWith("!");
47+
let base = isDisable ? rule.substring(1) : rule;
48+
const includeSubdirs = base.endsWith("*");
49+
if (includeSubdirs) base = base.substring(0, base.length - 1);
50+
base = ensureLeadingAndTrailingSlash(base);
51+
const glob = `${base}${includeSubdirs ? "*" : ""}`;
52+
const regexString = glob
53+
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
54+
.replace(/(\/?)\*/g, "($1.*)?");
55+
return new RegExp(`^${regexString}$`).test(checkPath);
56+
}
57+
58+
function isExtensionEnabled(enablementConfig, extensionName, workspace) {
59+
const extensionConfig = enablementConfig?.[extensionName];
60+
let enabled = true;
61+
const allOverrides = extensionConfig?.overrides ?? [];
62+
const lexicalPath = ensureLeadingAndTrailingSlash(workspace);
63+
for (const rule of allOverrides) {
64+
if (overrideMatchesPath(rule, lexicalPath)) {
65+
enabled = !rule.startsWith("!");
66+
}
67+
}
68+
return enabled;
69+
}
70+
3871
function qwenMarkdownRuleSource(workspace, sourceLabel, precedence = "after-provider-rules") {
3972
return {
4073
type: "file",
@@ -66,7 +99,7 @@ function normalizeProvidedQwenRecord(record) {
6699
source: record.source ?? "user",
67100
installMatch: record.installMatch ?? "provided",
68101
type: record.type ?? "link",
69-
enablement: record.enablement ?? null,
102+
enablementConfig: record.enablementConfig ?? null,
70103
};
71104
}
72105

@@ -103,7 +136,7 @@ async function readQwenInstalledPluginState(options = {}) {
103136
source: installMarker.originSource ?? "user",
104137
installMatch: "qwen-extension-install",
105138
type: installMarker.type ?? "link",
106-
enablement: enablement[name] ?? null,
139+
enablementConfig: enablement,
107140
});
108141
}
109142
return {
@@ -113,32 +146,45 @@ async function readQwenInstalledPluginState(options = {}) {
113146
};
114147
}
115148

116-
async function collectQwenPluginMcpItems(pluginRoot, sourceLabel) {
149+
async function collectQwenPluginMcpItems(pluginRoot, sourceLabel, manifest) {
150+
const items = [];
151+
if (manifest?.mcpServers && typeof manifest.mcpServers === "object") {
152+
for (const [name, config] of Object.entries(manifest.mcpServers)) {
153+
items.push({
154+
name,
155+
scope: "plugin",
156+
sourceLabel,
157+
command: config.command ?? null,
158+
args: config.args ?? [],
159+
evidence: evidence(path.join(pluginRoot, "qwen-extension.json"), pluginRoot),
160+
});
161+
}
162+
}
117163
for (const candidate of [path.join(pluginRoot, ".mcp.json"), path.join(pluginRoot, "mcp.json")]) {
118164
if (await pathExists(candidate)) {
119-
return collectMcpFromConfig(candidate, "plugin", sourceLabel, pluginRoot);
165+
items.push(...(await collectMcpFromConfig(candidate, "plugin", sourceLabel, pluginRoot)));
120166
}
121167
}
122-
return [];
168+
return items;
123169
}
124170

125-
async function collectQwenPlugin(record) {
171+
async function collectQwenPlugin(record, workspace) {
126172
const pluginRoot = path.resolve(expandHome(record.installPath));
127173
if (!(await pathExists(pluginRoot))) {
128174
return null;
129175
}
130-
const metadataEvidencePath = await pluginMetadataEvidencePath(pluginRoot, [QWEN_PLUGIN_MANIFEST, ["package.json"]]);
131-
const manifest = (await readJson(path.join(pluginRoot, ...QWEN_PLUGIN_MANIFEST))) ?? {};
176+
const metadataEvidencePath = await pluginMetadataEvidencePath(pluginRoot, [QWEN_EXTENSION_MANIFEST, ["package.json"]]);
177+
const manifest = (await readJson(path.join(pluginRoot, ...QWEN_EXTENSION_MANIFEST))) ?? {};
132178
const packageJson = (await readJson(path.join(pluginRoot, "package.json"))) ?? {};
133179
const readme = await readText(path.join(pluginRoot, "README.md"), 6000);
134180
const heading = readme.match(/^#\s+(.+)$/mu)?.[1]?.trim();
135181
const rawDisplayName =
136-
manifest.interface?.displayName ||
137182
manifest.displayName ||
138183
packageJson.displayName ||
139184
heading ||
140185
titleCase(manifest.name || packageJson.name || record.name);
141186
const displayName = normalizePluginDisplayName(rawDisplayName, record.name);
187+
const enabled = isExtensionEnabled(record.enablementConfig, record.name, workspace);
142188
const plugin = {
143189
id: record.id,
144190
qwenExtensionId: record.name,
@@ -148,23 +194,19 @@ async function collectQwenPlugin(record) {
148194
sourceLabel: displayName,
149195
name: manifest.name || packageJson.name || record.name,
150196
displayName,
151-
description:
152-
manifest.interface?.shortDescription ||
153-
manifest.description ||
154-
packageJson.description ||
155-
"",
156-
publisher: { displayName: manifest.author?.name || manifest.interface?.developerName || titleCase(record.marketplaceName) },
197+
description: manifest.description || packageJson.description || "",
198+
publisher: { displayName: titleCase(record.marketplaceName) },
157199
version: record.version || manifest.version || packageJson.version,
158200
installSources: record.sources,
159201
installSource: record.source,
160202
installMatch: record.installMatch,
161203
installType: record.type,
162204
installRecordPath: record.installMarkerPath,
163-
enabled: record.enablement ? record.enablement.disabled !== true : true,
205+
enabled,
164206
evidence: evidence(metadataEvidencePath, path.dirname(path.dirname(pluginRoot))),
165207
};
166208
plugin.skills = await collectSkillFiles(path.join(pluginRoot, "skills"), "plugin", displayName, pluginRoot);
167-
plugin.mcpServers = await collectQwenPluginMcpItems(pluginRoot, displayName);
209+
plugin.mcpServers = await collectQwenPluginMcpItems(pluginRoot, displayName, manifest);
168210
plugin.rules = await collectRuleSources([
169211
directoryRuleSource(path.join(pluginRoot, "rules"), "plugin", displayName, pluginRoot),
170212
]);
@@ -177,10 +219,10 @@ async function collectQwenPlugin(record) {
177219
return plugin;
178220
}
179221

180-
async function collectQwenPlugins(records) {
222+
async function collectQwenPlugins(records, workspace) {
181223
const plugins = [];
182224
for (const record of records) {
183-
const plugin = await collectQwenPlugin(record);
225+
const plugin = await collectQwenPlugin(record, workspace);
184226
if (plugin) {
185227
plugins.push(plugin);
186228
}
@@ -199,19 +241,74 @@ async function collectQwenUserPrimitives(qwenHome) {
199241
const mcps = await pathExists(mcpPath)
200242
? (await collectMcpFromConfig(mcpPath, "user", "User", qwenHome)) ?? []
201243
: (await collectMcpItems(qwenHome, "user", "User", qwenHome)) ?? [];
244+
const settings = (await readJson(path.join(qwenHome, "settings.json"))) ?? {};
245+
if (settings.mcpServers && typeof settings.mcpServers === "object") {
246+
for (const [name, config] of Object.entries(settings.mcpServers)) {
247+
if (!mcps.some((m) => m.name === name)) {
248+
mcps.push({
249+
name,
250+
scope: "user",
251+
sourceLabel: "User",
252+
command: config.command ?? null,
253+
args: config.args ?? [],
254+
evidence: evidence(path.join(qwenHome, "settings.json"), qwenHome),
255+
});
256+
}
257+
}
258+
}
259+
const hooks = await collectHookItems(qwenHome, "user", "User", qwenHome);
260+
if (Array.isArray(settings.hooks)) {
261+
for (const hook of settings.hooks) {
262+
if (hook?.command && !hooks.some((h) => h.command === hook.command)) {
263+
hooks.push({
264+
command: hook.command,
265+
scope: "user",
266+
sourceLabel: "User",
267+
evidence: evidence(path.join(qwenHome, "settings.json"), qwenHome),
268+
});
269+
}
270+
}
271+
}
202272
return {
203273
skills: await collectSkillFiles(path.join(qwenHome, "skills"), "user", "User", qwenHome),
204274
subagents: await collectMarkdownItems(path.join(qwenHome, "agents"), "subagent", "user", "User", qwenHome),
205275
rules: await collectRuleSources([directoryRuleSource(path.join(qwenHome, "rules"), "user", "User", qwenHome)]),
206276
commands: await collectMarkdownItems(path.join(qwenHome, "commands"), "command", "user", "User", qwenHome),
207-
hooks: await collectHookItems(qwenHome, "user", "User", qwenHome),
277+
hooks,
208278
mcps,
209279
};
210280
}
211281

212282
async function collectQwenWorkspacePrimitives(workspace) {
213283
const sourceLabel = await workspaceSourceLabel(workspace);
214284
const project = await collectWorkspaceRootPrimitives(path.join(workspace, ".qwen"), sourceLabel, workspace);
285+
const settings = (await readJson(path.join(workspace, ".qwen", "settings.json"))) ?? {};
286+
if (settings.mcpServers && typeof settings.mcpServers === "object") {
287+
for (const [name, config] of Object.entries(settings.mcpServers)) {
288+
if (!project.mcps.some((m) => m.name === name)) {
289+
project.mcps.push({
290+
name,
291+
scope: "project",
292+
sourceLabel,
293+
command: config.command ?? null,
294+
args: config.args ?? [],
295+
evidence: evidence(path.join(workspace, ".qwen", "settings.json"), workspace),
296+
});
297+
}
298+
}
299+
}
300+
if (Array.isArray(settings.hooks)) {
301+
for (const hook of settings.hooks) {
302+
if (hook?.command && !project.hooks.some((h) => h.command === hook.command)) {
303+
project.hooks.push({
304+
command: hook.command,
305+
scope: "project",
306+
sourceLabel,
307+
evidence: evidence(path.join(workspace, ".qwen", "settings.json"), workspace),
308+
});
309+
}
310+
}
311+
}
215312
return {
216313
...project,
217314
rules: [
@@ -237,7 +334,7 @@ export async function collectQwenCustomizeInventory(options = {}) {
237334
? await readQwenInstalledPluginState({ ...options, qwenHome })
238335
: { records: [], source: "not-authorized", installRecordFiles: [] };
239336
const [plugins, user, project] = await Promise.all([
240-
includeUserHome ? collectQwenPlugins(installState.records ?? []) : [],
337+
includeUserHome ? collectQwenPlugins(installState.records ?? [], workspace) : [],
241338
includeUserHome ? collectQwenUserPrimitives(qwenHome) : emptyPrimitives(),
242339
collectQwenWorkspacePrimitives(workspace),
243340
]);

scripts/coding-agent-practices/inventory.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,7 @@ function providerScope(options = {}, platform = options.platform ?? "qoder") {
447447
codexAppPath: options.codexAppPath ?? options["codex-app-path"],
448448
claudeHome: options.claudeHome ?? options["claude-home"],
449449
claudeStatePath: options.claudeStatePath ?? options["claude-state"] ?? options["claude-state-path"],
450+
qwenHome: options.qwenHome ?? options["qwen-home"],
450451
};
451452
}
452453

@@ -535,7 +536,7 @@ function customizeSurface({ provider, group, scope, type, label, basePath, items
535536
async function buildConfiguredAssetSurfaces(inventory, scope) {
536537
const provider = scope.platform;
537538
const projectBase = scope.workspace;
538-
const userBase = inventory.cursorHome ?? inventory.qoderHome ?? inventory.codexHome ?? inventory.claudeHome;
539+
const userBase = inventory.cursorHome ?? inventory.qoderHome ?? inventory.codexHome ?? inventory.claudeHome ?? inventory.qwenHome;
539540
const surfaceTypes = [
540541
["skills", "skills", "Skills"],
541542
["subagents", "agents", "Agents"],
@@ -637,6 +638,7 @@ export async function collectProviderInventory(options = {}) {
637638
codexAppPath: scope.codexAppPath,
638639
claudeHome: scope.claudeHome,
639640
claudeStatePath: scope.claudeStatePath,
641+
qwenHome: scope.qwenHome,
640642
includeUserHome: scope.includeUserHome,
641643
includeGlobalHooks: scope.includeGlobalHooks,
642644
});

0 commit comments

Comments
 (0)