Skip to content

Proj/multi repos knowledge sharing develop#320

Closed
wzlng wants to merge 5 commits intomainfrom
proj/multi-repos-knowledge-sharing-develop
Closed

Proj/multi repos knowledge sharing develop#320
wzlng wants to merge 5 commits intomainfrom
proj/multi-repos-knowledge-sharing-develop

Conversation

@wzlng
Copy link
Copy Markdown
Contributor

@wzlng wzlng commented Apr 4, 2026

No description provided.

wzlng and others added 4 commits April 4, 2026 17:37
Replace imperative link commands with two declarative config files:
- .brv/workspaces.json (local) — workspace paths for cross-project search
- .brv/context-tree/dependencies.json (remote) — hub bundle tracking

New commands: brv workspace add/remove, brv hub install (all),
brv hub uninstall, /workspace, /hub install, /hub uninstall.

Agent: multi-source search indexing, write guard for linked projects,
score boosting for local results, source metadata in search results.

Infrastructure: git-like .brv/ walk-up resolution, bundles install to
context-tree/bundles/{id}/, status shows workspaces + dependencies,
all TUI/oclif operations go through daemon transport events.
- Use node:path relative() for cross-platform path containment check
  in write-guard instead of hardcoded '/' separator
- Add --format json flag to hub uninstall for scripting consistency
- Warn on malformed workspaces.json/dependencies.json instead of
  silent empty fallback
- Use resolveRequiredProjectPath in hub install-all and uninstall
  handlers instead of falling back to daemon's process.cwd()
- Report skipped (already installed) bundles in install-all results
- Warn on unsupported glob patterns in workspaces.json
- Wire WorkspaceHandler into daemon feature-handlers
- Fix bundle install path to bundles/{id}/ subdirectory
- Fix dependencies.json to store entry id instead of name
- Move install-all logic from oclif to server via INSTALL_ALL event
- Add TUI /hub install and /hub uninstall commands
- Fix lint errors (import ordering, no-await-in-loop, scoping)
…ng' into proj/multi-repos-knowledge-sharing-develop

# Conflicts:
#	src/server/infra/transport/handlers/status-handler.ts
#	src/shared/transport/types/dto.ts
#	src/tui/features/status/utils/format-status.ts
@wzlng wzlng requested a review from bao-byterover April 4, 2026 15:36
@wzlng wzlng marked this pull request as ready for review April 5, 2026 01:50
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 5, 2026

Claude finished @wzlng's task in 5m 14s —— View job


Code Review

  • Gather context and understand the PR
  • Review core domain knowledge files
  • Review server infrastructure changes
  • Review agent/tool changes
  • Review TUI and oclif command changes
  • Review tests
  • Post final review with inline comments

Summary

This PR implements multi-repo knowledge sharing — a well-scoped feature that lets a ByteRover project link to other projects and include their knowledge in searches. The overall design is clean: declarative config (workspaces.json), read-only semantics for linked trees enforced by write-guard.ts, and a cache-invalidation strategy that tracks both local and linked file mtimes. The new domain layer is well-tested.

Issues Found

Bug (write-guard)

  • write-guard.ts:24resolve(targetPath) resolves relative paths against process.cwd() instead of projectRoot. Current callers all pass absolute paths so this doesn't fail today, but it's a fragile implicit contract. Fix this →

Exit codes on failure

  • workspace add and workspace remove oclif commands use this.log() even on failure, so callers/scripts always get exit code 0. Should use this.error(result.message, {exit: 1}) when !result.success. Fix this →

Design concerns

  • workspaces-operations.ts:17addWorkspace validates the target is a brv project but not projectRoot itself. Writing workspaces.json to a non-initialized directory silently creates orphaned config.
  • hub-handler.ts:157 — Already-installed check hardcodes the bundle path (BUNDLES_DIR) duplicating knowledge from performInstall. If the install path changes, this check silently diverges.

Non-determinism

  • workspaces-resolver.ts:59readdirSync order is filesystem-dependent, making source ordering non-deterministic across platforms for glob patterns like packages/*. Adding .sort() would make it stable.

Pre-existing issues surfaced by this PR (follow-up candidates)

  • query-executor.ts supplementEntitySearches deduplicates by result.path alone, but two projects can have files at the same relative path. result.symbolPath (which includes the [alias]: prefix for linked results) is the correct dedup key.
  • tryDirectSearchResponse reads full content using the local context tree path for all results, which silently falls back to excerpts for linked results.

Test Coverage

  • New domain files (workspaces-schema, workspaces-operations, workspaces-resolver, dependencies-schema, knowledge-source, load-knowledge-sources) are well tested ✓
  • write-guard is tested ✓
  • Missing: WorkspaceHandler (transport layer), HubHandler.handleUninstall/handleInstallAll, QueryExecutor fingerprint changes for linked workspaces

What's Good

  • Clean domain separation — all knowledge-sharing logic is in server/core/domain/knowledge/
  • workspaces-resolver.ts correctly uses realpathSync for symlink-safe deduplication
  • Cache invalidation correctly tracks workspaces.json mtime alongside file mtimes
  • The [alias]:path namespace convention for linked symbol paths is consistently applied across the search index and result rendering
  • Write guard is applied in both the sandbox writeFile SDK and the write_file tool — good coverage of the two write paths


const localContextTree = resolve(projectRoot, BRV_DIR, CONTEXT_TREE_DIR)

const canonicalTarget = tryRealpath(resolve(targetPath))
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.

issue: resolve(targetPath) resolves relative paths against process.cwd(), not projectRoot. If a caller ever passes a relative targetPath, the guard would silently compare against the wrong directory.

While current callers (write-file-tool, curate-service) do pass absolute paths, this is a fragile contract. Prefer making the intent explicit:

Suggested change
const canonicalTarget = tryRealpath(resolve(targetPath))
const canonicalTarget = tryRealpath(resolve(projectRoot, targetPath))

* Computes relative path from projectRoot to targetPath.
* Validates target is a brv project and prevents duplicates.
*/
export function addWorkspace(projectRoot: string, targetPath: string): OperationResult {
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.

suggestion: addWorkspace validates that the target is a brv project, but doesn't verify that projectRoot itself is one. If called with a non-brv directory as projectRoot, writeWorkspacesFile will happily create .brv/workspaces.json without a corresponding .brv/config.json.

The WorkspaceHandler (transport layer) trusts resolveProjectPath to return a valid project path, which is fine. But for a standalone API this could be confusing. Consider adding a check:

const projectConfig = join(projectRoot, BRV_DIR, PROJECT_CONFIG_FILE)
if (!existsSync(projectConfig)) {
  return {message: `Not a ByteRover project: ${projectRoot}`, success: false}
}

const toInstall: string[] = []
const skipped: string[] = []
for (const id of entries) {
const bundleDir = join(projectPath, BRV_DIR, CONTEXT_TREE_DIR, BUNDLES_DIR, id)
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.

nit: The already-installed check hard-codes the same bundle path logic that performInstall uses internally. If the install path ever changes (e.g., the bundle directory moves), this skip-check would silently report bundles as "already installed" when they're not, or would re-install when skipped says it shouldn't.

Consider extracting a shared helper (or expose a isInstalled(projectPath, entryId) method on hubInstallService) so there's a single source of truth for "is this bundle installed".

}),
)

this.log(result.message)
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.

issue: On failure (result.success === false), the command logs the error message but exits with code 0. Callers/scripts that check the exit code won't detect the failure.

Suggested change
this.log(result.message)
if (result.success) {
this.log(result.message)
} else {
this.error(result.message, {exit: 1})
}

async (client) =>
client.requestWithAck<WorkspaceOperationResponse, WorkspaceRemoveRequest>(
WorkspaceEvents.REMOVE,
{path: args.path},
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.

issue: Same exit code problem as workspace add — failure exits with code 0. Apply the same fix: this.error(result.message, {exit: 1}) when !result.success.


try {
const entries = readdirSync(parentDir, {withFileTypes: true})
return entries
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.

nit: readdirSync doesn't guarantee ordering — the order of resolved sources will be filesystem-dependent, making the index non-deterministic across platforms/filesystems.

Suggested change
return entries
return entries
.filter((e) => e.isDirectory())
.sort((a, b) => a.name.localeCompare(b.name))
.map((e) => join(parentDir, e.name))

(r) => `### ${r.title}\n**Source**: .brv/context-tree/${r.path}\n\n${r.excerpt}`,
)
const sections = highConfidenceResults.map((r) => {
const source = r.sourceAlias ? `[${r.sourceAlias}]:${r.path}` : `.brv/context-tree/${r.path}`
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.

nit: The source label looks good. However, tryDirectSearchResponse (line ~487, unchanged in this PR) still reads full document content via join(BRV_DIR, CONTEXT_TREE_DIR, result.path), which is the local context tree path and will silently fail for linked results (falling back to excerpt). This pre-exists the PR but is now more relevant — worth a follow-up fix using result.sourceContextTreeRoot ?? join(this.baseDirectory!, BRV_DIR, CONTEXT_TREE_DIR) as the base.

const basePath = resolve(this.workingDirectory, rawBasePath)

// Write guard: block writes to linked context trees
const guardError = validateWriteTarget(basePath, this.workingDirectory)
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.

nit: The write guard here blocks the entire batch of operations if the basePath itself is in a linked context tree. This is correct behavior, but the error message includes the guard string (e.g., "Cannot write to knowledge-linked project...") directly without any context about which operation triggered it. The user sees operations.length identical failure entries which might be confusing for a batch operation with mixed targets.

@leehpham leehpham changed the base branch from develop to main April 5, 2026 09:59
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 6, 2026

Claude finished @wzlng's task in 3m 27s —— View job


Code Review — Score: 3/5

  • Gather context and understand the PR
  • Review new domain files (knowledge/, workspaces-*)
  • Review write-guard and security changes
  • Review oclif commands (workspace add/remove, hub uninstall)
  • Review transport handlers and executor changes
  • Review TUI changes
  • Review tests
  • Post final review with inline comments

Summary

This PR implements multi-repo knowledge sharing cleanly: declarative config (workspaces.json), read-only semantics enforced by write-guard.ts, and cache invalidation that tracks both local and linked file mtimes. The domain layer is well-structured and well-tested. However, several bugs flagged in the previous review remain unaddressed, which is the main reason for the 3/5 score.


Issues Still Unfixed from Previous Review

Bug — write-guard.ts:24 (inline)
resolve(targetPath) resolves relative paths against process.cwd() instead of projectRoot. Current callers pass absolute paths so it doesn't break today, but it's a latent bug and fragile contract. Fix: resolve(projectRoot, targetPath).

Bug — exit codes (add.ts, remove.ts)
workspace add and workspace remove both use this.log() on failure, exiting with code 0. Callers and CI pipelines can't detect failures. Use this.error(result.message, {exit: 1}) on !result.success.

Nit — non-deterministic glob ordering (workspaces-resolver.ts:59)
readdirSync order is filesystem-dependent. Add .sort((a, b) => a.name.localeCompare(b.name)) for deterministic, platform-stable source ordering.

Suggestion — missing projectRoot validation (workspaces-operations.ts:17)
addWorkspace validates the target but not projectRoot itself. Writing to a non-initialized projectRoot causes an unguarded ENOENT from writeFileSync.


New Issues

Issue — alias collision (workspaces-resolver.ts:96)
alias = basename(canonicalDir) uses the directory name. Two linked projects with the same directory name (e.g. both named utils) get the same alias, making [alias]:path ambiguous in search results. Consider detecting collisions and appending a numeric suffix, or using deriveSourceKey directly.

Nit — tryDirectSearchResponse silent fallback for linked results (query-executor.ts:237)
The pre-fetched context attribution is now correct ([alias]:path for linked results ✓), but tryDirectSearchResponse (unchanged) still reads full content via the local context tree path for all results — silently falling back to excerpts for linked files. Follow-up fix: use result.sourceContextTreeRoot as the base directory when reading.

Pre-existing — supplementEntitySearches deduplication bug
supplementEntitySearches deduplicates by result.path alone. With multi-repo enabled, two projects can have files at the same relative path — the dedup key should be result.symbolPath (which includes the [alias]: prefix). Not in the diff so no inline comment, but worth a follow-up.

Nit — handleInstallAll hardcodes bundle path
Line 157: join(projectPath, BRV_DIR, CONTEXT_TREE_DIR, BUNDLES_DIR, id) duplicates the install path logic from performInstall. If the path ever changes, the already-installed check silently diverges. Flagged in the previous review and still present.


What's Good

  • Clean domain separation — all knowledge-sharing logic contained in server/core/domain/knowledge/
  • realpathSync for symlink-safe deduplication — handles macOS /tmp → /private/tmp correctly
  • Cache invalidation tracks workspaces.json mtime alongside file mtimes — invalidates on link changes
  • Write guard applied in both the curate-service SDK path and the write_file tool — full coverage
  • [alias]:path namespace convention consistently applied through indexing → rendering → prompts
  • New domain files are thoroughly tested


try {
const entries = readdirSync(parentDir, {withFileTypes: true})
return entries
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.

nit: readdirSync order is filesystem-dependent, so the resolved source list is non-deterministic across platforms (ext4 vs APFS vs HFS+). Flagged in the previous review and still unfixed.

Suggested change
return entries
return entries
.filter((e) => e.isDirectory())
.sort((a, b) => a.name.localeCompare(b.name))
.map((e) => join(parentDir, e.name))

seen.add(canonicalDir)

return {
alias: basename(canonicalDir),
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.

issue: alias is set to basename(canonicalDir), which is just the directory name. If two linked projects sit at different paths but share the same directory name (e.g. ~/work/app/packages/utils and ~/other/packages/utils), they get the same alias. The [alias]:path namespace would then be ambiguous — search results from both projects would render with the same prefix.

Consider using a truncated hash of canonicalDir (you already have deriveSourceKey for this) or appending a numeric suffix on collision inside resolveWorkspaces.

* Computes relative path from projectRoot to targetPath.
* Validates target is a brv project and prevents duplicates.
*/
export function addWorkspace(projectRoot: string, targetPath: string): OperationResult {
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.

suggestion: addWorkspace validates the target is a brv project, but doesn't check that projectRoot itself is initialized. If called with an uninitialized directory as projectRoot, writeWorkspacesFile will throw an ENOENT at the writeFileSync call because .brv/ doesn't exist.

The WorkspaceHandler relies on resolveRequiredProjectPath to guarantee a valid project path, so this is safe in production. But for standalone API usage (and to match the defensive style of the target check), add:

const projectConfig = join(projectRoot, BRV_DIR, PROJECT_CONFIG_FILE)
if (!existsSync(projectConfig)) {
  return {message: `Not a ByteRover project: ${projectRoot}`, success: false}
}

Flagged in the previous review and still unaddressed.

const sections = highConfidenceResults.map(
(r) => `### ${r.title}\n**Source**: .brv/context-tree/${r.path}\n\n${r.excerpt}`,
)
const sections = highConfidenceResults.map((r) => {
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.

nit: The source label for linked results is [alias]:path in the pre-fetched context, but tryDirectSearchResponse (further down, unchanged by this PR) still reads content via join(BRV_DIR, CONTEXT_TREE_DIR, result.path) — the local context tree path — for all results. For linked results this will silently fall back to result.excerpt. The attribution is now correct here; the content read in tryDirectSearchResponse is the remaining gap to fix in a follow-up.


const localContextTree = resolve(projectRoot, BRV_DIR, CONTEXT_TREE_DIR)

const canonicalTarget = tryRealpath(resolve(targetPath))
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.

issue: resolve(targetPath) resolves relative paths against process.cwd(), not projectRoot. This was flagged in the previous review and is still unfixed.

Current callers always pass absolute paths so it doesn't fail today, but any future caller passing a relative path will silently compare against the wrong directory.

Suggested change
const canonicalTarget = tryRealpath(resolve(targetPath))
const canonicalTarget = tryRealpath(resolve(projectRoot, targetPath))

}),
)

this.log(result.message)
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.

issue: Failure exits with code 0 — this.log() is used regardless of result.success. Scripts and CI that rely on exit codes will silently miss errors. Flagged in the previous review and still unfixed.

Suggested change
this.log(result.message)
if (result.success) {
this.log(result.message)
} else {
this.error(result.message, {exit: 1})
}

async (client) =>
client.requestWithAck<WorkspaceOperationResponse, WorkspaceRemoveRequest>(
WorkspaceEvents.REMOVE,
{path: args.path},
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.

issue: Same exit-code problem as workspace add. Apply the same conditional fix.

Suggested change
{path: args.path},
if (result.success) {
this.log(result.message)
} else {
this.error(result.message, {exit: 1})
}


const localContextTree = resolve(projectRoot, BRV_DIR, CONTEXT_TREE_DIR)

const canonicalTarget = tryRealpath(resolve(targetPath))
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.

issue: resolve(targetPath) resolves relative targetPath against process.cwd(), not projectRoot. This was flagged in the previous review and remains unfixed.

Current callers (write-file-tool, curate-service) always pass absolute paths so it doesn't break today, but the contract is fragile: any future caller passing a relative path will silently compare against the wrong directory.

Suggested change
const canonicalTarget = tryRealpath(resolve(targetPath))
const canonicalTarget = tryRealpath(resolve(projectRoot, targetPath))

}),
)

this.log(result.message)
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.

issue: Failure is logged via this.log() — exit code stays 0 even when result.success === false. Scripts and CI pipelines that check exit codes will silently miss failures. Flagged in the previous review and still unaddressed.

Suggested change
this.log(result.message)
if (result.success) {
this.log(result.message)
} else {
this.error(result.message, {exit: 1})
}

async (client) =>
client.requestWithAck<WorkspaceOperationResponse, WorkspaceRemoveRequest>(
WorkspaceEvents.REMOVE,
{path: args.path},
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.

issue: Same exit code problem as workspace add. this.log(result.message) always exits 0. Apply the same conditional fix.

Suggested change
{path: args.path},
if (result.success) {
this.log(result.message)
} else {
this.error(result.message, {exit: 1})
}

@wzlng wzlng closed this Apr 6, 2026
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.

1 participant