Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ observable behavior and compatibility, not every internal refactor.

### 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).

- Make `command describe` resolve exact registered leaf paths instead of
returning the parent command metadata.

Expand Down
79 changes: 79 additions & 0 deletions docs/specs/2026-07-31-ha02-provider-aware-checkup-plan.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 4 additions & 4 deletions roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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. |

Expand Down
41 changes: 27 additions & 14 deletions scripts/coding-agent-practices/checkup/apply.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,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/index.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"]);
Expand Down Expand Up @@ -57,14 +55,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");
}
Expand All @@ -76,7 +79,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, root, workspace, key: sourceKey(sourceRef) };
return { filePath, root, workspace, key: sourceKey(sourceRef), provider: provider || null };
}

async function assertSafeSourceTarget({ filePath, root }) {
Expand Down Expand Up @@ -223,7 +226,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,
});
await assertSafeSourceTarget(resolved);
const content = await readFile(resolved.filePath, "utf8");
const expectedFingerprint = action.sourceFingerprints?.[resolved.key];
Expand Down Expand Up @@ -383,16 +389,23 @@ 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 = [];
for (const actionId of selectedActionIds) {
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}`);
Expand Down
62 changes: 62 additions & 0 deletions scripts/coding-agent-practices/checkup/contract.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { createHash } from "node:crypto";
import os from "node:os";
import path from "node:path";

import { expandHome } from "../../session-analysis/index.mjs";

export const CHECKUP_KIND = "harness-customization-checkup";
export const CHECKUP_SCHEMA_VERSION = 1;
export const CHECKUP_REPORT_EVIDENCE_VERSION = 1;
Expand Down Expand Up @@ -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) {
Expand Down
50 changes: 43 additions & 7 deletions scripts/coding-agent-practices/checkup/plan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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");
Expand All @@ -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,
Expand All @@ -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",
},
};
}
Expand Down
Loading