From 8fc43e97dd0aaaddb42259529d80895415868a12 Mon Sep 17 00:00:00 2001 From: linmaog <12575858+linmaog@user.noreply.gitee.com> Date: Fri, 31 Jul 2026 10:23:32 +0800 Subject: [PATCH] fix(checkup): bind plan and apply to explicit provider homes Checkup plans for Codex, Cursor, and other non-Qoder hosts were emitting qodercli disable mutations, and provider-home paths always resolved through Qoder home. Make plan/apply provider-aware so only Qoder can use qoder-cli, other hosts stay manual-review, and source fingerprints bind to the plan's host root (roadmap HA-02). Spec: docs/specs/2026-07-31-ha02-provider-aware-checkup-plan.md Test: node --test test/harness-checkup.test.mjs test/support-declarations.test.mjs test/doc-link-graph.test.mjs (38 pass) Co-authored-by: Grok (Grok 4.5) --- CHANGELOG.md | 8 ++ ...-07-31-ha02-provider-aware-checkup-plan.md | 79 +++++++++++++++++++ roadmap.md | 8 +- .../coding-agent-practices/checkup/apply.mjs | 41 ++++++---- .../checkup/contract.mjs | 62 +++++++++++++++ .../coding-agent-practices/checkup/plan.mjs | 50 ++++++++++-- .../coding-agent-practices/checkup/scan.mjs | 33 ++++++-- test/harness-checkup.test.mjs | 64 ++++++++++++++- 8 files changed, 312 insertions(+), 33 deletions(-) create mode 100644 docs/specs/2026-07-31-ha02-provider-aware-checkup-plan.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0806589..28aa33f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ observable behavior and compatibility, not every internal refactor. ## Unreleased +### Fixed + +- Checkup plan/apply is provider-aware: only `provider=qoder` can emit or execute + `qodercli` disable mutations. Other hosts keep candidates as `manual-review` + until a provider-native apply contract exists. `provider-home` source + resolution and fingerprints bind to the explicit host home (for example + Codex uses `codexHome`, never Qoder home). + ## 0.4.0 - 2026-07-30 ### Added diff --git a/docs/specs/2026-07-31-ha02-provider-aware-checkup-plan.md b/docs/specs/2026-07-31-ha02-provider-aware-checkup-plan.md new file mode 100644 index 0000000..953a0e1 --- /dev/null +++ b/docs/specs/2026-07-31-ha02-provider-aware-checkup-plan.md @@ -0,0 +1,79 @@ +# Provider-aware Checkup Plan Binding + +Make checkup plan and apply fail closed across hosts so non-Qoder providers +never receive `qodercli` mutations and `provider-home` paths always resolve +through the plan's explicit provider. + +## Traceability + +- Spec ID: 2026-07-31-ha02-provider-aware-checkup-plan +- Story: roadmap.md Host Adapter Enablement HA-02 +- Status: Implemented + +## Intent + +Checkup plan currently hard-codes Qoder mutation and home binding: + +1. `buildCheckupPlan()` always emits `qoder-cli` / `qodercli` disable actions + for Skill, MCP, and plugin candidates, including when `scan.provider` is + `codex`, `cursor`, or another host. +2. `resolveSourceRef()` and scan fingerprinting always bind `provider-home` to + Qoder home (`qoderHome` / `~/.qoder`), so a patch can resolve into another + host's configuration root. + +Roadmap HA-02 requires provider-aware planning and fail-closed binding: +source references and provider-home paths bind to one explicit provider, and +no plan routes through another host's executor or configuration root. + +## Acceptance Scenarios + +- AC-1: For `provider=qoder`, plan still emits disable-first `qoder-cli` + mutations for eligible Skill, MCP, and plugin candidates, and apply still + executes confirmed Qoder argv without a shell. +- AC-2: For non-Qoder providers (`codex`, `cursor`, `claude`, and other + inventory hosts), candidate plans never contain `qoder-cli` or `qodercli` + mutations. Unsupported automatic disable becomes `manual-review`. +- AC-3: `provider-home` source resolution and scan fingerprints use the home + owned by the explicit plan/scan provider. A Codex plan with only + `codexHome` never resolves into a Qoder home path. +- AC-4: Apply rejects `qoder-cli` mutations when `plan.provider !== "qoder"` + before spawning any process. +- AC-5: Existing Qoder plan digest stability for pure Qoder fixtures remains + covered; focused checkup tests pass. + +## Non-goals + +- Do not implement HA-01 capability profiles (`full-session`, + `inventory-only`, `unsupported`). +- Do not invent provider-native automatic apply for Codex, Cursor, Claude, + Qwen, Copilot, Pi, or WorkBuddy. +- Do not redesign the checkup finding model, observation window, or cleanup + eligibility census. +- Do not change Secret Guard, report, or session-analysis behavior outside + checkup plan/apply home binding. + +## Plan and Tasks + +1. Add a shared provider-home resolver that maps an explicit provider to the + matching inventory/options home field (`qoderHome`, `codexHome`, + `cursorHome`, `claudeHome`, `qwenHome`, `copilotHome`, `piHome`, + `workbuddyHome`) with portable defaults only when needed for that provider. +2. Update `relativeSourceRef`, fingerprint collection, and `resolveSourceRef` + to require/use that provider-owned home for `provider-home` bases. +3. Gate `qoderMutation()` and plan confirmation so only `provider=qoder` can + produce or advertise automatic `qoder-cli` apply; other providers emit + `manual-review` and mark apply unavailable when no applyable mutation + remains. +4. Make apply refuse cross-provider `qoder-cli` execution. +5. Add focused regression tests for AC-1 through AC-4. + +## Test and Review Evidence + +- `node --test test/harness-checkup.test.mjs` +- Manual reproduction before/after: + `buildCheckupPlan({ provider: "codex", ...candidate })` must not emit + `qodercli`. +- Risk: low/medium safety fix; Qoder path remains the only automatic apply + executor. Residual risk: source-patch still applies only to allowed + extensions under workspace or the bound provider home; no new host mutation + contract is claimed. diff --git a/roadmap.md b/roadmap.md index 3cefea7..f514cdc 100644 --- a/roadmap.md +++ b/roadmap.md @@ -125,9 +125,9 @@ not duplicate that matrix. Completed adapter-enablement work includes the help-only `agent-customize` path (`A-04`), support-declaration consistency tests (`A-06`), deterministic Cursor -plugin-ID matching removal (`U-01`), and the new-host contribution checklist -(`X-02`). These are prerequisites, not proof that a closed improvement loop is -available. +plugin-ID matching removal (`U-01`), the new-host contribution checklist +(`X-02`), and provider-aware checkup plan/apply fail-closed binding (`HA-02`). +These are prerequisites, not proof that a closed improvement loop is available. ## System Invariants @@ -157,7 +157,7 @@ execution dependency above. | Done | Priority | ID | Capability | Acceptance | | --- | --- | --- | --- | --- | | [ ] | P0 | HA-01 | Add explicit `full-session`, `inventory-only`, and `unsupported` checkup capability profiles. | A configured-only provider can complete inventory without session evidence and cannot emit cleanup or mutation candidates. | -| [ ] | P0 | HA-02 | Make checkup planning provider-aware and fail closed. | Source references and provider-home paths bind to one explicit provider; no plan can route through another host's executor or configuration root. | +| [x] | P0 | HA-02 | Make checkup planning provider-aware and fail closed. | Source references and provider-home paths bind to one explicit provider; no plan can route through another host's executor or configuration root. | | [ ] | P1 | HA-03 | Close provider-specific evidence-depth gaps. | Model, usage, hook, lifecycle, and mutation fields remain unavailable until a stable native source and drift fixtures exist; provider-native apply stays read-only where no accepted mutation contract exists. | | [ ] | P2 | HA-04 | Add Host x OS native smoke coverage. | Every claimed host/OS combination separately proves install or discovery, inventory, evidence collection, analysis, output validation, upgrade or reinstall, and privacy boundaries. | diff --git a/scripts/coding-agent-practices/checkup/apply.mjs b/scripts/coding-agent-practices/checkup/apply.mjs index d3392eb..77f3cdc 100644 --- a/scripts/coding-agent-practices/checkup/apply.mjs +++ b/scripts/coding-agent-practices/checkup/apply.mjs @@ -10,13 +10,11 @@ import { stat, writeFile, } from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { collectAgentCustomizeInventory } from "../../agent-customize/index.mjs"; import { hookConfigurationDigest } from "../../agent-customize/core/items.mjs"; -import { expandHome } from "../../session-analysis/paths.mjs"; -import { CHECKUP_KIND, CHECKUP_SCHEMA_VERSION } from "./contract.mjs"; +import { CHECKUP_KIND, CHECKUP_SCHEMA_VERSION, resolveProviderHome } from "./contract.mjs"; import { computePlanDigest } from "./plan.mjs"; const ALLOWED_SOURCE_EXTENSIONS = new Set([".json", ".md", ".mdc", ".markdown"]); @@ -54,14 +52,19 @@ function containedPath(root, relativePath) { export function resolveSourceRef(sourceRef, options = {}) { const workspace = path.resolve(options.workspace ?? process.cwd()); - const providerHome = path.resolve(expandHome( - options.qoderHome ?? options["qoder-home"] ?? process.env.QODER_HOME ?? path.join(os.homedir(), ".qoder"), - )); - const root = sourceRef?.base === "workspace" - ? workspace - : sourceRef?.base === "provider-home" - ? providerHome - : null; + const provider = String(options.provider ?? sourceRef?.provider ?? "").toLowerCase(); + let root = null; + if (sourceRef?.base === "workspace") { + root = workspace; + } else if (sourceRef?.base === "provider-home") { + if (!provider) { + throw new Error("provider-home source patches require an explicit provider"); + } + if (sourceRef.provider && String(sourceRef.provider).toLowerCase() !== provider) { + throw new Error("sourceRef.provider does not match the apply provider"); + } + root = resolveProviderHome(provider, options); + } if (!root) { throw new Error("source patches support only workspace and provider-home owners"); } @@ -73,7 +76,7 @@ export function resolveSourceRef(sourceRef, options = {}) { if (!ALLOWED_SOURCE_EXTENSIONS.has(path.extname(filePath).toLowerCase())) { throw new Error("source patch target must be a supported instruction or hook source file"); } - return { filePath, workspace, key: sourceKey(sourceRef) }; + return { filePath, workspace, key: sourceKey(sourceRef), provider: provider || null }; } function replaceLines(content, patch) { @@ -159,7 +162,10 @@ async function atomicReplace(filePath, content) { export async function applySourcePatch(action, options = {}) { const sourceRef = action.mutation?.sourceRef; - const resolved = resolveSourceRef(sourceRef, options); + const resolved = resolveSourceRef(sourceRef, { + ...options, + provider: options.provider ?? sourceRef?.provider, + }); const content = await readFile(resolved.filePath, "utf8"); const expectedFingerprint = action.sourceFingerprints?.[resolved.key]; if (!expectedFingerprint || sha256(content) !== expectedFingerprint) { @@ -311,6 +317,7 @@ export async function applyCheckupPlan(plan, options = {}, dependencies = {}) { throw new Error("current evidence or source fingerprints drifted after planning"); } + const planProvider = String(plan.provider ?? "").toLowerCase(); const executeQoder = dependencies.executeQoder ?? createQoderCliExecutor(dependencies.executorOptions); const queryQoder = dependencies.queryQoder ?? createQoderCliVerificationExecutor(dependencies.executorOptions); const results = []; @@ -318,9 +325,15 @@ export async function applyCheckupPlan(plan, options = {}, dependencies = {}) { const action = actionById.get(actionId); try { if (action.mutation?.type === "source-patch") { - const result = await applySourcePatch(action, options); + const result = await applySourcePatch(action, { + ...options, + provider: planProvider, + }); results.push({ actionId, operation: action.operation, ...result }); } else if (action.mutation?.type === "qoder-cli") { + if (planProvider !== "qoder") { + throw new Error("qoder-cli mutations are only supported when plan.provider is qoder"); + } const execution = await executeQoder(action.mutation, { workspace: options.workspace }); if (!execution.ok) { throw new Error(`Qoder CLI operation failed with exit code ${execution.exitCode}`); diff --git a/scripts/coding-agent-practices/checkup/contract.mjs b/scripts/coding-agent-practices/checkup/contract.mjs index 18baac9..c2b2e0d 100644 --- a/scripts/coding-agent-practices/checkup/contract.mjs +++ b/scripts/coding-agent-practices/checkup/contract.mjs @@ -1,6 +1,9 @@ import { createHash } from "node:crypto"; +import os from "node:os"; import path from "node:path"; +import { expandHome } from "../../session-analysis/paths.mjs"; + export const CHECKUP_KIND = "harness-customization-checkup"; export const CHECKUP_SCHEMA_VERSION = 1; export const CHECKUP_REPORT_EVIDENCE_VERSION = 1; @@ -145,6 +148,65 @@ export function safeLabel(value, fallback = "unknown") { return text.slice(0, 160); } +/** Inventory/options field names for each checkup host's configuration root. */ +export const PROVIDER_HOME_FIELDS = Object.freeze({ + qoder: "qoderHome", + codex: "codexHome", + cursor: "cursorHome", + claude: "claudeHome", + qwen: "qwenHome", + copilot: "copilotHome", + pi: "piHome", + workbuddy: "workbuddyHome", +}); + +export function providerHomeField(provider) { + return PROVIDER_HOME_FIELDS[String(provider ?? "").toLowerCase()] ?? null; +} + +/** + * Resolve the configuration-home root for an explicit provider. + * Prefer inventory/options values, then well-known env defaults for that host. + * Never falls back across providers (for example Codex must not use Qoder home). + */ +export function resolveProviderHome(provider, source = {}) { + const normalized = String(provider ?? "").toLowerCase(); + const field = providerHomeField(normalized); + if (!field) { + throw new Error(`unsupported provider for provider-home binding: ${provider ?? "missing"}`); + } + const kebab = field.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`); + const explicit = source[field] ?? source[kebab]; + if (explicit) { + return path.resolve(expandHome(String(explicit))); + } + + const env = process.env ?? {}; + const userHome = os.homedir(); + const defaults = { + qoder: env.QODER_HOME ?? path.join(userHome, ".qoder"), + codex: env.CODEX_HOME ?? path.join(userHome, ".codex"), + cursor: env.CURSOR_HOME ?? path.join(userHome, ".cursor"), + claude: env.CLAUDE_CONFIG_DIR ?? env.CLAUDE_HOME ?? path.join(userHome, ".claude"), + qwen: env.QWEN_HOME ?? path.join(userHome, ".qwen"), + copilot: env.COPILOT_HOME ?? path.join(userHome, ".copilot"), + pi: env.PI_CODING_AGENT_DIR ?? path.join(userHome, ".pi", "agent"), + workbuddy: env.WORKBUDDY_DIR ?? path.join(userHome, ".workbuddy"), + }; + return path.resolve(expandHome(defaults[normalized])); +} + +/** + * Read the provider-owned home from an inventory object without inventing a + * foreign-host fallback. Returns null when the inventory does not expose that + * host's home field. + */ +export function inventoryProviderHome(provider, inventory = {}) { + const field = providerHomeField(provider); + if (!field || !inventory?.[field]) return null; + return path.resolve(String(inventory[field])); +} + export function countBy(items, key) { const counts = {}; for (const item of items) { diff --git a/scripts/coding-agent-practices/checkup/plan.mjs b/scripts/coding-agent-practices/checkup/plan.mjs index f4ae1d0..1c4a0c3 100644 --- a/scripts/coding-agent-practices/checkup/plan.mjs +++ b/scripts/coding-agent-practices/checkup/plan.mjs @@ -20,6 +20,10 @@ function scopeForPlugin(scope) { return scope === "project" ? "project" : "user"; } +function manualReviewMutation() { + return { type: "manual-review", executable: null, argv: [] }; +} + function qoderMutation(asset) { if (asset.kind === "skill") { return { @@ -38,7 +42,15 @@ function qoderMutation(asset) { argv: ["plugin", "disable", asset.pluginId ?? asset.name, "--scope", scopeForPlugin(asset.scope)], }; } - return { type: "manual-review", executable: null, argv: [] }; + return manualReviewMutation(); +} + +function providerMutation(provider, asset) { + if (String(provider ?? "").toLowerCase() === "qoder") { + return qoderMutation(asset); + } + // No accepted provider-native automatic disable contract for other hosts yet. + return manualReviewMutation(); } function inverseMutation(mutation) { @@ -49,16 +61,26 @@ function inverseMutation(mutation) { }; } +function boundSourceRef(scan, sourceRef) { + if (!sourceRef) return sourceRef; + const provider = String(scan.provider ?? "").toLowerCase(); + return { + ...sourceRef, + provider: sourceRef.provider ?? provider, + }; +} + function buildAction(scan, finding) { const asset = findAsset(scan, finding); if (!asset) return null; + const provider = String(scan.provider ?? "").toLowerCase(); const duplicateHook = finding.evidence?.some((item) => item.code === "hook-duplicate-registration"); const fingerprintKey = sourceRefKey(asset.sourceRef); const fingerprint = fingerprintKey ? scan.configuredInventory.sourceFingerprints?.[fingerprintKey] : null; const mutation = duplicateHook && asset.sourceRef && fingerprint ? { type: "source-patch", - sourceRef: asset.sourceRef, + sourceRef: boundSourceRef(scan, asset.sourceRef), patch: { type: "remove-hook-registration", event: asset.event, @@ -67,11 +89,16 @@ function buildAction(scan, finding) { expectedConfigurationDigest: asset.configurationDigest, }, } - : qoderMutation(asset); + : providerMutation(provider, asset); + const automaticDisable = mutation.type === "qoder-cli"; const actionId = finding.candidateActions[0] ?? `disable-${asset.kind}-${shortHash(asset.id)}`; return { id: actionId, - operation: duplicateHook ? "deduplicate" : asset.kind === "hook" ? "manual-review" : "disable", + operation: duplicateHook + ? "deduplicate" + : asset.kind === "hook" || !automaticDisable + ? "manual-review" + : "disable", target: { id: asset.id, kind: asset.kind, @@ -83,7 +110,9 @@ function buildAction(scan, finding) { ? "An identical later hook registration has the same private full-config digest and owning source; remove only that duplicate." : asset.kind === "hook" ? "Attributed runtime evidence crossed the hook review budget; inspect matcher, duplication, blocking need, and command work before any confirmed source patch." - : "No mapped use was observed in a complete all-eligible, scope-appropriate window outside the install grace period.", + : automaticDisable + ? "No mapped use was observed in a complete all-eligible, scope-appropriate window outside the install grace period." + : `No provider-native automatic disable contract is available for ${provider || "this provider"}; review the candidate manually.`, evidenceStatus: finding.status, expectedReduction: { configuredAssets: 1, @@ -109,6 +138,10 @@ function buildAction(scan, finding) { }; } +function mutationIsApplyable(mutation) { + return mutation?.type === "qoder-cli" || mutation?.type === "source-patch"; +} + export function buildCheckupPlan(scan) { if (scan?.kind !== CHECKUP_KIND || scan?.phase !== "scan") { throw new Error("checkup plan requires a phase=scan checkup payload"); @@ -118,6 +151,7 @@ export function buildCheckupPlan(scan) { .map((finding) => buildAction(scan, finding)) .filter(Boolean) .sort((left, right) => left.id.localeCompare(right.id)); + const applyableActions = actions.filter((action) => mutationIsApplyable(action.mutation)); const planCore = { provider: scan.provider, workspace: scan.workspace, @@ -143,8 +177,10 @@ export function buildCheckupPlan(scan) { confirmation: { requiredForApply: true, selectedActionIdsRequired: true, - applyAvailable: true, - reason: "apply requires this plan digest, explicit selected action ids, and current source evidence", + applyAvailable: applyableActions.length > 0, + reason: applyableActions.length > 0 + ? "apply requires this plan digest, explicit selected action ids, and current source evidence" + : "no provider-native automatic apply contract is available for the selected actions", }, }; } diff --git a/scripts/coding-agent-practices/checkup/scan.mjs b/scripts/coding-agent-practices/checkup/scan.mjs index e9b5b07..69e20b3 100644 --- a/scripts/coding-agent-practices/checkup/scan.mjs +++ b/scripts/coding-agent-practices/checkup/scan.mjs @@ -15,8 +15,10 @@ import { assertFindingStatus, countBy, digest, + inventoryProviderHome, normalizeCheckupOptions, publicSource, + resolveProviderHome, safeLabel, shortHash, } from "./contract.mjs"; @@ -59,18 +61,23 @@ function sourceIdentity(item) { return [item.kind, item.scope, item.name, item.sourceLabel, item.ownerId].filter(Boolean).join(":"); } -function relativeSourceRef(item, inventory) { +function relativeSourceRef(item, inventory, provider) { if (!item.filePath || item.scope === "plugin") return null; const filePath = path.resolve(item.filePath); + const providerHome = inventoryProviderHome(provider, inventory); const roots = [ { base: "workspace", root: inventory.workspace }, - { base: "provider-home", root: inventory.qoderHome }, + { base: "provider-home", root: providerHome }, ].filter((entry) => entry.root); for (const entry of roots) { const root = path.resolve(entry.root); const relativePath = path.relative(root, filePath); if (relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath)) { - return { base: entry.base, relativePath: relativePath.split(path.sep).join("/") }; + return { + base: entry.base, + relativePath: relativePath.split(path.sep).join("/"), + provider: String(provider ?? inventory.provider ?? "").toLowerCase() || undefined, + }; } } return null; @@ -127,7 +134,7 @@ function publicAsset(item, ownerId, sourceRef = null) { }; } -function configuredAssets(inventory = {}) { +function configuredAssets(inventory = {}, provider = inventory.provider) { const assets = []; const plugins = Array.isArray(inventory.plugins) ? inventory.plugins : []; for (const plugin of plugins) { @@ -140,7 +147,7 @@ function configuredAssets(inventory = {}) { const ownerId = owner ? publicAsset({ ...owner, kind: "plugin", scope: pluginScope(owner) }).id : undefined; - assets.push(publicAsset(item, ownerId, relativeSourceRef(item, inventory))); + assets.push(publicAsset(item, ownerId, relativeSourceRef(item, inventory, provider))); } } const projectMcpNames = new Set( @@ -755,7 +762,7 @@ export function buildCheckupScan({ minimumSessions: normalized.minimumSessions, newInstallGraceDays: normalized.newInstallGraceDays, }; - const assets = configuredAssets(inventory); + const assets = configuredAssets(inventory, normalized.provider); const observed = observedIndexes(inventory, sessionResult.facets); const repeatedFrictionTriage = normalized.frictionSignals.length > 0 ? buildRepeatedFrictionTriage({ @@ -906,10 +913,22 @@ export async function runCheckupScan(options = {}, dependencies = {}) { options: normalized, }); const fingerprints = {}; + const providerHome = inventoryProviderHome(normalized.provider, inventory) + ?? (() => { + try { + return resolveProviderHome(normalized.provider, normalized); + } catch { + return null; + } + })(); for (const sourceRef of scan.configuredInventory.assets.map((asset) => asset.sourceRef).filter(Boolean)) { const key = `${sourceRef.base}:${sourceRef.relativePath}`; if (fingerprints[key]) continue; - const root = sourceRef.base === "workspace" ? normalized.workspace : inventory.qoderHome; + const root = sourceRef.base === "workspace" + ? normalized.workspace + : sourceRef.base === "provider-home" + ? providerHome + : null; if (!root) continue; const filePath = path.resolve(root, sourceRef.relativePath); const relative = path.relative(path.resolve(root), filePath); diff --git a/test/harness-checkup.test.mjs b/test/harness-checkup.test.mjs index 550d32b..4bbfc69 100644 --- a/test/harness-checkup.test.mjs +++ b/test/harness-checkup.test.mjs @@ -572,7 +572,7 @@ test("source patch apply verifies fingerprints, backs up, writes atomically, and }); test("source patch resolution rejects traversal, generated runtime files, and plugin caches", () => { - const options = { workspace: "/tmp/workspace", qoderHome: "/tmp/qoder-home" }; + const options = { workspace: "/tmp/workspace", provider: "qoder", qoderHome: "/tmp/qoder-home" }; assert.throws( () => resolveSourceRef({ base: "workspace", relativePath: "../outside.json" }, options), /escapes/u, @@ -587,6 +587,68 @@ test("source patch resolution rejects traversal, generated runtime files, and pl ); }); +test("non-qoder plans never emit qodercli and provider-home binds to the explicit host", () => { + const baseScan = buildFixtureScan(); + for (const provider of ["codex", "cursor", "claude", "qwen", "copilot", "pi", "workbuddy"]) { + const scan = structuredClone(baseScan); + scan.provider = provider; + const plan = buildCheckupPlan(scan); + assert.equal(plan.provider, provider); + for (const action of plan.actions) { + assert.notEqual(action.mutation?.type, "qoder-cli"); + assert.notEqual(action.mutation?.executable, "qodercli"); + if (Array.isArray(action.mutation?.argv)) { + assert.equal(action.mutation.argv.includes("qodercli"), false); + } + } + assert.equal( + plan.actions.every((action) => action.mutation?.type === "manual-review"), + true, + ); + assert.equal(plan.confirmation.applyAvailable, false); + } + + const codexPath = resolveSourceRef( + { base: "provider-home", relativePath: "hooks.json", provider: "codex" }, + { workspace: "/tmp/ws", provider: "codex", codexHome: "/tmp/codex-home-only", qoderHome: "/tmp/qoder-home-only" }, + ); + assert.equal(codexPath.filePath, path.resolve("/tmp/codex-home-only/hooks.json")); + assert.equal(codexPath.filePath.startsWith(path.resolve("/tmp/qoder-home-only")), false); + + assert.throws( + () => resolveSourceRef( + { base: "provider-home", relativePath: "hooks.json" }, + { workspace: "/tmp/ws", codexHome: "/tmp/codex-home-only", qoderHome: "/tmp/qoder-home-only" }, + ), + /explicit provider/u, + ); +}); + +test("apply rejects qoder-cli mutations when plan.provider is not qoder", async () => { + const plan = buildCheckupPlan(buildFixtureScan()); + plan.provider = "codex"; + plan.actions[0].mutation = { + type: "qoder-cli", + executable: "qodercli", + argv: ["skills", "disable", "stale-flow", "--scope", "workspace"], + }; + plan.planDigest = computePlanDigest(plan); + let calls = 0; + const result = await applyCheckupPlan(plan, { + workspace: "/tmp/demo", + confirmationDigest: plan.planDigest, + selectedActionIds: [plan.actions[0].id], + currentPlan: plan, + }, { + executeQoder: async () => { + calls += 1; + return { ok: true, exitCode: 0 }; + }, + }); + assert.equal(result.results[0].status, "failed"); + assert.equal(calls, 0); +}); + test("same sanitized Hook identity with different arguments never produces a deduplication action", () => { const workspace = "/tmp/better-harness-checkup-hook-distinct"; const filePath = path.join(workspace, ".qoder", "settings.json");