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
86 changes: 86 additions & 0 deletions docs/specs/2026-08-01-42-claude-config-root-asset-inventory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Explicit Claude config workspaces remain project-scoped

## Traceability

- Spec ID: 42-claude-config-root-asset-inventory
- Story: QoderAI/better-harness#42
- Status: Implemented

## Intent

When a caller explicitly selects the Claude config directory itself as the
analysis workspace, Better Harness currently looks for project assets under a
nonexistent nested `.claude` directory and suppresses the real root-level
assets as ambient user-home data. Treat assets physically contained by that
designated workspace as project-authorized evidence while preserving the
default exclusion for data and install paths outside the workspace.

## Acceptance Scenarios

- AC-1: With `workspace` and `claudeHome` resolving to the same directory,
`asset-baseline --platform claude` without `--include-user-home` inventories
root-level `skills/`, `commands/`, `agents/`, `rules/`, `CLAUDE.md`, and
`settings*.json` assets instead of probing `<workspace>/.claude/*`.
- AC-2: The same baseline includes enabled installed Plugins whose index and
install roots are contained by the designated workspace, including their
public Plugin surface, without changing the reported
`scope.includeUserHome: false` authority.
- AC-3: The overlap does not authorize adjacent Claude state or Plugin install
roots outside the designated workspace. A state file beside the workspace
and an installed-Plugin record whose canonical path escapes the workspace
remain excluded unless `--include-user-home` is explicitly passed.
- AC-4: Canonically equivalent workspace and Claude-home paths, including a
symlink alias where the platform supports it, receive the same bounded
treatment. Normal project workspaces keep their existing
`<workspace>/.claude/*` discovery behavior.

## Non-goals

- Do not broaden ambient user-home collection or change the public meaning of
`--include-user-home`.
- Do not add or infer configured assets for non-Claude providers; issue #42
provides Claude-specific path-layout evidence.
- Do not read raw state, credentials, Hook bodies, or Plugin content outside
the existing metadata-only contracts, and do not mutate Claude configuration.
- Do not claim that a configured asset or Plugin executed successfully.

## Plan and Tasks

1. Detect canonical identity between the selected workspace and Claude config
root in the Claude provider, using filesystem-aware path comparison.
2. Route that overlap through the existing project primitive collectors with
the workspace itself as the Claude asset root. Read the installed Plugin
index from the workspace, but retain only canonical install roots contained
by it when user-home authority is absent.
3. Mark contained Plugin metadata and children as workspace-scoped so lint and
public inventory gates include them without admitting unrelated Plugin
assets.
4. Add a focused asset-baseline fixture covering root assets, contained and
escaping Plugin records, adjacent state exclusion, and the normal project
path regression. Add a provider-level symlink assertion when supported.

## Test and Review Evidence

- AC-1–AC-3: `node --test test/agent-asset-baseline.test.mjs`
- AC-4 and provider scope details: `node --test test/agent-customize.test.mjs`
- Regression: `node --test test/agent-lint.test.mjs
test/better-harness-evidence-bundle.test.mjs`
- Full suite and package boundary: `npm test` and `npm run pack:verify`
- Risk review: inspect the final diff for canonical containment, symlink escape,
outside-state exclusion, disabled Plugin filtering, Windows path portability,
and unchanged normal-project behavior.

## Implementation Evidence

- `node --test test/agent-customize.test.mjs
test/agent-asset-baseline.test.mjs test/agent-lint.test.mjs
test/better-harness-evidence-bundle.test.mjs` passed 87/87.
- `npm test` passed 1095/1095, including the normal-project regression,
designated-root fixture, canonical symlink alias, and symlink escape cases.
- `node scripts/doc-link-graph/cli.mjs skills/better-harness` followed by
`node --test test/doc-link-graph.test.mjs` passed 6/6 with no generated diff.
- `npm run pack:verify` passed with 382 npm entries and 405 runtime ZIP entries
using an isolated writable npm cache.
- Review confirmed that `scope.includeUserHome` remains false, adjacent Claude
state stays unread, outside and symlink-escaping Plugin roots stay excluded,
and the existing `<workspace>/.claude/*` fixture remains green.
1 change: 1 addition & 0 deletions scripts/agent-customize/core/items.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,7 @@ export function flattenPlugins(plugins, key) {
pluginId: plugin.id,
pluginName: plugin.name,
pluginEnabled: plugin.enabled,
workspaceScoped: item.workspaceScoped ?? plugin.workspaceScoped,
sourceLabel: plugin.displayName,
})),
);
Expand Down
106 changes: 90 additions & 16 deletions scripts/agent-customize/providers/claude.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,31 @@ async function pathInsideRoot(root, relativePath) {
return candidate;
}

async function pathsReferToSameRoot(left, right) {
const [resolvedLeft, resolvedRight] = await Promise.all([
realpath(path.resolve(left)).catch(() => path.resolve(left)),
realpath(path.resolve(right)).catch(() => path.resolve(right)),
]);
return path.relative(resolvedLeft, resolvedRight) === "";
}

async function canonicalPathInsideRoot(root, candidate) {
if (!root || !candidate || !(await pathExists(candidate))) return false;
let realRoot;
let realCandidate;
try {
[realRoot, realCandidate] = await Promise.all([
realpath(path.resolve(root)),
realpath(path.resolve(candidate)),
]);
} catch {
return false;
}
const relative = path.relative(realRoot, realCandidate);
return relative === ""
|| (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
}

async function componentRoots(pluginRoot, manifest, key, fallback, options = {}) {
const declared = pluginPathValues(manifest?.[key]);
const requested = options.addDefault
Expand Down Expand Up @@ -210,14 +235,22 @@ async function collectUserRules(claudeHome) {
];
}

async function collectProjectRules(workspace, sourceLabel) {
async function collectProjectRules(workspace, sourceLabel, claudeRoot = path.join(workspace, ".claude")) {
const fileDefinitions = [
[path.join(workspace, "CLAUDE.md"), "CLAUDE.md", "claude-project", "claude-project"],
[path.join(workspace, ".claude", "CLAUDE.md"), ".claude/CLAUDE.md", "claude-project", "claude-project"],
[
path.join(claudeRoot, "CLAUDE.md"),
path.relative(workspace, path.join(claudeRoot, "CLAUDE.md")).split(path.sep).join("/") || "CLAUDE.md",
"claude-project",
"claude-project",
],
[path.join(workspace, "CLAUDE.local.md"), "CLAUDE.local.md", "claude-local", "claude-local"],
];
const files = [];
const visited = new Set();
for (const [filePath, name, sourceKind, precedence] of fileDefinitions) {
if (visited.has(filePath)) continue;
visited.add(filePath);
files.push(...await collectMarkdownFileRuleItem(filePath, "project", sourceLabel, workspace, {
name,
sourceKind,
Expand All @@ -227,7 +260,7 @@ async function collectProjectRules(workspace, sourceLabel) {
return [
...files,
...await collectMarkdownRuleItems(
path.join(workspace, ".claude", "rules"),
path.join(claudeRoot, "rules"),
"project",
sourceLabel,
workspace,
Expand Down Expand Up @@ -268,9 +301,14 @@ async function collectClaudeUserPrimitives(claudeHome, statePath, state, workspa
};
}

async function collectClaudeWorkspacePrimitives(workspace, statePath, state, includeState) {
async function collectClaudeWorkspacePrimitives(
workspace,
statePath,
state,
includeState,
claudeRoot = path.join(workspace, ".claude"),
) {
const sourceLabel = await workspaceSourceLabel(workspace);
const claudeRoot = path.join(workspace, ".claude");
const stateMcps = includeState
? await collectStateMcp(
state,
Expand All @@ -285,7 +323,7 @@ async function collectClaudeWorkspacePrimitives(workspace, statePath, state, inc
return {
skills: await collectSkillFiles(path.join(claudeRoot, "skills"), "project", sourceLabel, workspace),
subagents: await collectMarkdownItems(path.join(claudeRoot, "agents"), "subagent", "project", sourceLabel, workspace),
rules: await collectProjectRules(workspace, sourceLabel),
rules: await collectProjectRules(workspace, sourceLabel, claudeRoot),
commands: await collectMarkdownItems(path.join(claudeRoot, "commands"), "command", "project", sourceLabel, workspace),
hooks: await collectClaudeHooks(
[path.join(claudeRoot, "settings.json"), path.join(claudeRoot, "settings.local.json")],
Expand All @@ -305,11 +343,12 @@ function enabledPluginMap(settings) {
: new Map();
}

async function readClaudeSettings(claudeHome, workspace, includeUserHome) {
async function readClaudeSettings(claudeHome, workspace, includeUserHome, workspaceIsClaudeHome) {
const projectRoot = workspaceIsClaudeHome ? workspace : path.join(workspace, ".claude");
const [user, project, local] = await Promise.all([
includeUserHome ? readJson(path.join(claudeHome, "settings.json")) : undefined,
readJson(path.join(workspace, ".claude", "settings.json")),
readJson(path.join(workspace, ".claude", "settings.local.json")),
includeUserHome && !workspaceIsClaudeHome ? readJson(path.join(claudeHome, "settings.json")) : undefined,
readJson(path.join(projectRoot, "settings.json")),
readJson(path.join(projectRoot, "settings.local.json")),
]);
return {
user: enabledPluginMap(user),
Expand Down Expand Up @@ -454,6 +493,7 @@ async function collectClaudePlugin(record, settings, workspace) {
projectPath: record.projectPath,
applicable,
enabled,
workspaceScoped: record.workspaceScoped === true,
enabledSettingScope: configured?.scope,
evidence: evidence(metadataPath, claudeHomeForPluginIndex(pluginRoot)),
};
Expand Down Expand Up @@ -483,22 +523,55 @@ async function collectClaudePlugins(records, settings, workspace) {
return plugins.sort(sortByName);
}

async function annotateWorkspacePluginRecords(records, workspace) {
const annotated = [];
for (const record of records) {
const workspaceScoped = await canonicalPathInsideRoot(workspace, record.installPath);
annotated.push({ ...record, workspaceScoped });
}
return annotated;
}

export async function collectClaudeCustomizeInventory(options = {}) {
const claudeHome = resolveClaudeHome(options);
const claudeStatePath = resolveClaudeStatePath(options, claudeHome);
const workspace = normalizeWorkspace(options.workspace ?? process.cwd());
const includeUserHome = options.includeUserHome !== false;
const workspaceIsClaudeHome = await pathsReferToSameRoot(workspace, claudeHome);
const claudeProjectRoot = workspaceIsClaudeHome ? workspace : path.join(workspace, ".claude");
const state = includeUserHome ? await readJson(claudeStatePath) : undefined;
const [settings, installState] = await Promise.all([
readClaudeSettings(claudeHome, workspace, includeUserHome),
includeUserHome
const [settings, rawInstallState] = await Promise.all([
readClaudeSettings(claudeHome, workspace, includeUserHome, workspaceIsClaudeHome),
includeUserHome || workspaceIsClaudeHome
? readClaudeInstalledPluginState(claudeHome, options)
: Promise.resolve({ source: "not-authorized", records: [], indexPath: undefined, indexVersion: undefined }),
]);
const annotatedRecords = workspaceIsClaudeHome
? await annotateWorkspacePluginRecords(rawInstallState.records, workspace)
: rawInstallState.records;
const installState = {
...rawInstallState,
records: includeUserHome ? annotatedRecords : annotatedRecords.filter((record) => record.workspaceScoped === true),
};
const userPrimitives = includeUserHome && !workspaceIsClaudeHome
? await collectClaudeUserPrimitives(claudeHome, claudeStatePath, state, workspace)
: includeUserHome
? {
...emptyPrimitives(),
mcps: await collectStateMcp(
state,
(value) => value?.mcpServers,
claudeStatePath,
"user",
"User",
claudeHome,
),
}
: emptyPrimitives();
const [plugins, user, project] = await Promise.all([
includeUserHome ? collectClaudePlugins(installState.records, settings, workspace) : [],
includeUserHome ? collectClaudeUserPrimitives(claudeHome, claudeStatePath, state, workspace) : emptyPrimitives(),
collectClaudeWorkspacePrimitives(workspace, claudeStatePath, state, includeUserHome),
includeUserHome || workspaceIsClaudeHome ? collectClaudePlugins(installState.records, settings, workspace) : [],
Promise.resolve(userPrimitives),
collectClaudeWorkspacePrimitives(workspace, claudeStatePath, state, includeUserHome, claudeProjectRoot),
]);
return {
generatedAt: new Date().toISOString(),
Expand All @@ -518,6 +591,7 @@ export async function collectClaudeCustomizeInventory(options = {}) {
unresolvedProjectPluginCount: plugins.filter((plugin) => plugin.installSource === "project" && !plugin.applicable).length,
marketplaceCatalogsAreNotInstallEvidence: true,
runtimeMcpProbeExecuted: false,
designatedClaudeHomeWorkspace: workspaceIsClaudeHome,
},
unsupported: [
"enterprise managed settings",
Expand Down
3 changes: 3 additions & 0 deletions scripts/agent-lint/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -966,6 +966,9 @@ function assetScopeIncluded(item, options = {}) {
if (item.scope === "project") {
return true;
}
if (item.scope === "plugin" && item.workspaceScoped === true) {
return true;
}
if ((options.includeUserHome ?? options["include-user-home"]) && item.scope === "user") {
return true;
}
Expand Down
15 changes: 10 additions & 5 deletions scripts/coding-agent-practices/inventory.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,7 @@ function customizeItem(item) {
originScope: item.originScope,
originRoute: item.originRoute,
effectiveTarget: item.effectiveTarget,
workspaceScoped: item.workspaceScoped,
pluginId: item.pluginId,
pluginName: item.pluginName,
pluginEnabled: item.pluginEnabled,
Expand Down Expand Up @@ -525,9 +526,11 @@ function scopeItems(inventory, collection, scope) {
.map(customizeItem);
}

function pluginScopedItems(inventory, collection) {
function pluginScopedItems(inventory, collection, includeUserHome) {
return (inventory.manage?.[collection] ?? [])
.filter((item) => item.scope === "plugin" && item.pluginEnabled !== false)
.filter((item) => item.scope === "plugin"
&& item.pluginEnabled !== false
&& (includeUserHome || item.workspaceScoped === true))
.map(customizeItem);
}

Expand Down Expand Up @@ -619,8 +622,10 @@ async function buildConfiguredAssetSurfaces(inventory, scope) {
}
}

if (scope.includeUserHome) {
const effectivePlugins = inventory.plugins.filter((plugin) => plugin.enabled !== false);
const workspaceScopedPlugins = inventory.plugins.filter((plugin) => plugin.workspaceScoped === true);
if (scope.includeUserHome || workspaceScopedPlugins.length > 0) {
const effectivePlugins = inventory.plugins.filter((plugin) =>
plugin.enabled !== false && (scope.includeUserHome || plugin.workspaceScoped === true));
if (effectivePlugins.length > 0) {
surfaces.push(customizeSurface({
provider,
Expand All @@ -633,7 +638,7 @@ async function buildConfiguredAssetSurfaces(inventory, scope) {
}));
}
for (const [collection, type, label] of surfaceTypes) {
const items = pluginScopedItems(inventory, collection);
const items = pluginScopedItems(inventory, collection, scope.includeUserHome);
if (items.length > 0) {
surfaces.push(customizeSurface({
provider,
Expand Down
Loading