Skip to content

Resolve Claude workspace roots so Context Health can scan them - #273

Merged
Sanjay Singh (san360) merged 3 commits into
microsoft:mainfrom
mostafaseyedan:fix/claude-workspace-root-resolution
Nov 7, 2026
Merged

Sanjay Singh (san360) merged 3 commits into
microsoft:mainfrom
mostafaseyedan:fix/claude-workspace-root-resolution

Conversation

@mostafaseyedan

Copy link
Copy Markdown
Contributor

Problem

The Context Health page discards every Claude workspace, then reports conclusions drawn from whatever is left. On a real log set it showed:

  • Overall Score 7/100, Agentic Readiness 20/100
  • 1 Active Workspaces, 0/1 With Context Files
  • ✗ Context Files — "No workspaces have context instruction files"

None of that was true. The same machine has CLAUDE.md / AGENTS.md in four separate projects.

resolveWorkspaceRoot in src/core/config-health-helpers.ts routes claude-* ids to resolveClaudeRoot(ws.path), which expects ~/.claude/projects/<encoded>/ and parses cwd out of the first .jsonl line. But ws.path is already the project root — the same thing codex-* and opencode-* ids get, and those take the fs.existsSync branch and resolve fine:

claude--home-me-AIOps   741 req  root=UNRESOLVED  ws.path=/home/me/AIOps
claude--home-me-recon   232 req  root=UNRESOLVED  ws.path=/home/me/Recon/recon

readdirSync finds no .jsonl in a project root, the helper returns null, getWorkspaceAnalysisContext returns null, and buildWorkspaceHealth drops the workspace. Nothing is logged, so the page presents a near-empty sample as a finished score.

Only Claude is affected.

Fix

Use ws.path when it is a real project directory, and keep resolveClaudeRoot as the fallback for paths that genuinely point at a Claude session store. A small isProjectRoot helper distinguishes the two and also makes both branches reject paths that no longer exist — previously the codex/opencode branch did an existsSync while the Claude branch had no equivalent guard.

Impact

Measured on the same log set, before → after:

before after
Workspaces scanned 1 7
Overall score 7/100 34/100
Agentic readiness 0 30
Context Files signal "none" 4/7 workspaces

The surviving workspaces now scan correctly, e.g. recon reports AGENTS.md, CLAUDE.md, .claude/skills/playwright-cli/SKILL.md, .claude/settings.local.json.

Testing

Three unit tests added: ws.path already a project root, the legacy session-store path still resolving via cwd, and a non-existent path returning null. Verified the first fails against the old implementation (1 failed / 38 passed) and all pass with the fix (39 passed).

npm run typecheck and npm run lint clean (0 errors); 62 config-health and analyzer-config tests pass.

Every Claude workspace is silently dropped from the Context Health page,
so the page reports "No workspaces have context instruction files" and
an agentic readiness of 0 even when the projects are well configured.

resolveWorkspaceRoot routes claude-* ids to resolveClaudeRoot, which
expects ~/.claude/projects/<encoded> and reads cwd out of the first
.jsonl line. But ws.path is already the project root, exactly as it is
for codex-* and opencode-* ids. readdirSync finds no .jsonl there, the
helper returns null, and buildWorkspaceHealth discards the workspace.

Only Claude is affected: the other CLI harnesses take the existsSync
branch and resolve correctly.

Use ws.path when it is a real project directory, keeping the session
file lookup as a fallback for paths that do still point at a Claude
session store. Both branches now reject paths that do not exist.

On a real log set this takes Context Health from 1 usable workspace to
7, the overall score from 7/100 to 34/100, agentic readiness from 0 to
30, and correctly reports 4 of 7 workspaces as having instruction files.

@san360 Sanjay Singh (san360) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you mostafaseyedan for tracking this down and for such a clear write-up! The before/after table and the explanation of why claude-* ids took a different branch from codex-*/opencode-* made the root cause easy to follow, and the regression test that fails on the old implementation is exactly what we like to see.

What we verified

  • Reproduced the bug on current main: a Claude Code project with CLAUDE.md, AGENTS.md and a .claude/skills/*/SKILL.md is shown in Context Health as "Unable to resolve workspace root path", and in the unfiltered view it is dropped entirely.
  • With the PR merged onto current main, the same project resolves and its three context files are listed (workspace score goes from 12 to 67). We checked this with real Claude Code sessions in a few small test repos, including a folder name with a space and a non-ASCII character, and Copilot sessions in the same folder are unchanged.
  • A nice side benefit: on main, a project that happens to contain a .jsonl data file was resolved through that file's first line and could show another project's context files. With your change it resolves to its own folder.
  • Your new test fails against main's implementation (1 failed / 38 passed) and passes with the fix. Typecheck, lint, knip, lockfile-lint, the full unit suite (3673 passed), build and bundle-size all pass locally.

One thing we'd like to adjust before merging

When the project folder recorded in the Claude sessions no longer exists (renamed, moved or deleted), ws.path isn't the encoded session folder: addSession in src/core/parser-harnesses.ts falls back to the Claude store root, ~/.claude/projects, itself. isProjectRoot only recognises .../.claude/projects/<encoded>, so it accepts ~/.claude/projects as a project root. As a result:

  1. Every Claude workspace whose folder is gone resolves to the same root, so dedupeWorkspaceHealth merges them into one tile with all their requests and no config files. With two such projects (60 requests each) next to two live ones, the overall score went from 45 to 15 and the merged tile was the largest one on the map.
  2. These workspaces show "Create a .github/copilot-instructions.md…" suggestions instead of "Unable to resolve workspace root path".
  3. Review Context Files would build its payload from the file tree of ~/.claude/projects (the encoded project folder names and transcript file names) rather than from a project.

On main these workspaces are skipped, so this is new with the PR. The inline suggestion below treats both ~/.claude/projects and ~/.claude/projects/<encoded> as session stores (by exact folder names rather than a substring match on .claude). With it, the live-project fix is unchanged and the deleted-folder case behaves as it does on main. Here's a test that fails on the current PR and passes with the suggestion (it could go in the existing describe('resolveWorkspaceRoot') block near the top of the test file):

it('returns null when the project folder is gone and ws.path fell back to ~/.claude/projects', () => {
  const base = makeTempDir();
  const store = path.join(base, '.claude', 'projects');
  fs.mkdirSync(path.join(store, '-tmp-deleted-project'), { recursive: true });
  expect(resolveWorkspaceRoot('claude--tmp-deleted-project', { id: 'claude--tmp-deleted-project', name: 'deleted-project', path: store })).toBeNull();
});

A couple of small optional notes are inline.

Next steps

Once that's in, a maintainer will approve the CI run for this fork (it's waiting for approval, which is normal for a first contribution). Thanks again, this makes Context Health much more useful for Claude Code users!

Comment on lines +91 to +98
function isProjectRoot(p: string): boolean {
try {
if (!fs.statSync(p).isDirectory()) return false;
} catch {
return false;
}
return path.basename(path.dirname(p)) !== 'projects' || !path.dirname(p).includes('.claude');
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the project folder no longer exists, ws.path falls back to ~/.claude/projects itself (addSession in parser-harnesses.ts), not to ~/.claude/projects/<encoded>. This check doesn't match that path, so all such workspaces resolve to the store root and get merged into one tile with no config files. Checking exact folder names for both levels also avoids the substring match on .claude (for example a parent folder named .claude-tools):

Suggested change
function isProjectRoot(p: string): boolean {
try {
if (!fs.statSync(p).isDirectory()) return false;
} catch {
return false;
}
return path.basename(path.dirname(p)) !== 'projects' || !path.dirname(p).includes('.claude');
}
function isProjectRoot(p: string): boolean {
try {
if (!fs.statSync(p).isDirectory()) return false;
} catch {
return false;
}
return !isClaudeSessionStore(p);
}
/** `~/.claude/projects` itself, or one `<encoded>` folder inside it. */
function isClaudeSessionStore(p: string): boolean {
const dir = path.resolve(p);
const isStoreRoot = (d: string) => path.basename(d) === 'projects' && path.basename(path.dirname(d)) === '.claude';
return isStoreRoot(dir) || isStoreRoot(path.dirname(dir));
}

Comment thread src/core/config-health-helpers.ts Outdated
Comment on lines +77 to +79
// `ws.path` is normally the project root already, as it is for the other
// CLI harnesses. Fall back to reading `cwd` out of a session file for
// workspaces whose path still points at ~/.claude/projects/<encoded>.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: a small wording tweak so the comment matches what the parser produces today (the session's cwd when it exists, otherwise the store root):

Suggested change
// `ws.path` is normally the project root already, as it is for the other
// CLI harnesses. Fall back to reading `cwd` out of a session file for
// workspaces whose path still points at ~/.claude/projects/<encoded>.
// `ws.path` is the session's cwd when that folder still exists, as for the
// other CLI harnesses; otherwise addSession falls back to ~/.claude/projects.
// Keep the session-file lookup for paths that point into the session store.

Comment thread src/core/config-health-helpers.test.ts Outdated
});
});

describe('resolveWorkspaceRoot', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional nit: there's already a describe('resolveWorkspaceRoot') block near the top of this file (with the Codex case). Moving these tests into it and using the file's makeTempDir() helper would keep them together and handle cleanup automatically, without the try/finally blocks.

When a Claude project's folder no longer exists, ws.path falls back to
~/.claude/projects. isProjectRoot accepted it, so every such workspace
resolved to the store root and merged into one tile. Treat both the store
root and its <encoded> folders as session stores, matching exact folder
names. Move the resolveWorkspaceRoot tests into the existing block and use
makeTempDir. Suggested in review by @san360.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLDXsjE1DMDor8E6cSLBpH
@mostafaseyedan

Copy link
Copy Markdown
Contributor Author

Hi Sanjay Singh (@san360),

Thank you so much for the careful review, and for testing with real Claude Code sessions. The deleted-folder case was a great catch. I had not thought about addSession falling back to the store root.

I applied all three suggestions:

  • isProjectRoot now uses your isClaudeSessionStore check, so both ~/.claude/projects and ~/.claude/projects/<encoded> count as session stores, by exact folder names.
  • I added your test for a deleted folder whose ws.path fell back to ~/.claude/projects. It fails on the previous head of this PR and passes now.
  • I updated the comment wording and moved the Claude tests into the existing describe('resolveWorkspaceRoot') block with makeTempDir(), so the try/finally blocks are gone.

config-health-helpers.test.ts passes 40/40, and npm run check passes locally. Thanks again for your help!

@san360

Copy link
Copy Markdown
Contributor

Thanks a lot for the quick update, mostafaseyedan, and for folding in all three suggestions so cleanly!

We re-checked the new head (3415828) merged onto current main:

  • Deleted or moved project folders are now skipped again, exactly as on main. We tested real Claude Code sessions whose folders were moved away after the session, and a synthetic set with two such projects at 60 requests each. There's no merged tile any more, and Review Context Files no longer includes the ~/.claude/projects listing.
  • Live projects still resolve: a test repo with CLAUDE.md, AGENTS.md and a skill goes from "Unable to resolve" (12) to its three files (67), and a fresh session in a second repo behaves the same way.
  • Tests: the new deleted-folder test fails on the previous head and passes now. The project-root test still fails against main's implementation, as it should. config-health-helpers.test.ts passes 40/40, and the full unit suite (3674), typecheck, lint, knip, lockfile-lint, build and bundle size all pass locally.
  • Scope: the update only touches the two files from our review. Other dashboard pages are unaffected.
  • Together with feat: name Claude workspaces from the session cwd #362: we merged both PRs onto main and they combine without conflicts. Context Health results are identical with or without feat: name Claude workspaces from the session cwd #362; the only visible difference is the nicer workspace name from feat: name Claude workspaces from the session cwd #362.

From our side this looks good to merge once a maintainer approves the CI run for the fork. Thanks again for the fix and the careful follow-through!

@san360
Sanjay Singh (san360) merged commit 3bef02f into microsoft:main Nov 7, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants