Skip to content

feat(delegate): add delegate mode for host-agent code review#364

Closed
lizhengfeng101 wants to merge 1 commit into
mainfrom
feat/delegate-mode
Closed

feat(delegate): add delegate mode for host-agent code review#364
lizhengfeng101 wants to merge 1 commit into
mainfrom
feat/delegate-mode

Conversation

@lizhengfeng101

Copy link
Copy Markdown
Collaborator

Summary

Add ocr delegate command that enables code review without requiring an API key. The host agent (e.g. Claude Code, Cursor) uses its own reasoning to review code changes, while OCR provides the deterministic data plane — bundle preparation, anti-hallucination validation, and report rendering.

Key changes:

  • ocr delegate — prepares a review bundle and emits a Markdown workflow for the host agent to follow
  • ocr delegate validate — verifies review comments against immutable bundle evidence (anti-hallucination)
  • ocr delegate report — renders validated comments as a formatted report
  • internal/reviewbundle — full package for bundle preparation, validation, reporting, and schema enforcement
  • internal/reviewfilter — file filtering by glob patterns
  • Neutral schema namingreview-bundle/v1, review-comments/v1 (no vendor prefix)
  • Dual-mode SKILL.md — auto-detects Mode A (OCR LLM) vs Mode B (delegate) via ocr llm test

How it works:

  1. Host agent runs ocr delegate → receives Markdown workflow + bundle file
  2. Agent reads bundle, reviews code, produces review-comments/v1 JSON
  3. ocr delegate validate checks every comment against bundle evidence
  4. ocr delegate report renders the final report

Test plan

  • make check passes (fmt + vet)
  • make test passes (all unit tests green)
  • 17 new test cases in delegate_cmd_test.go covering workspace/range/commit modes, validate/report roundtrips, flag validation, help output, and schema neutrality
  • Manual end-to-end test: ocr delegate → construct comments → validate → report

Add `ocr delegate` command that prepares a deterministic review bundle
and emits a Markdown workflow for host agents to follow. This enables
code review without requiring an API key — the host agent uses its own
reasoning to review, then validates and reports via OCR's anti-hallucination
pipeline.

Key components:
- `ocr delegate` — prepare bundle + emit workflow
- `ocr delegate validate` — verify comments against bundle evidence
- `ocr delegate report` — render validated comments as report
- `internal/reviewbundle` — bundle preparation, validation, reporting
- `internal/reviewfilter` — file filtering by glob patterns
- Neutral schema naming (review-bundle/v1, review-comments/v1)
- Updated SKILL.md and plugin commands to dual-mode (Mode A/B)
@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

🔍 OpenCodeReview found 19 issue(s) in this PR.

  • ✅ Successfully posted inline: 19 comment(s)

Comment on lines +84 to 85
delegate Prepare evidence and emit a host-agent review workflow
session, sessions List and inspect saved review sessions

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.

The delegate command is listed in the Commands section but is missing a corresponding "Use ... for more information" hint line at the bottom of the usage text, unlike all other subcommands (review, scan, rules, config, llm, session). For consistency, consider adding:

Use "ocr delegate -h" for more information about delegate.

Also consider adding an example usage line in the Examples section (e.g., ocr delegate --from master --to dev).

)

func writePrivateFile(path string, content []byte) error {
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)

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.

On Unix systems, when os.OpenFile opens an existing file with O_TRUNC, the mode argument (0o600) is ignored — it only takes effect when creating a new file via O_CREATE. If the file already exists with more permissive permissions (e.g., 0o644), this function will truncate and rewrite it while preserving the original permissive mode, potentially exposing sensitive bundle/validation data.

Consider explicitly setting permissions with os.Chmod after opening, or removing the file first before recreating it, to ensure the restrictive permissions are always applied.

Suggestion:

Suggested change
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
// Remove existing file first so O_CREATE applies the mode.
_ = os.Remove(path)
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600)

Comment on lines +230 to +232
if startLine-1 > totalLines || (totalLines > 0 && startLine-1 >= totalLines) {
return "", fmt.Errorf("file %q has only %d lines, requested range starting at %d", path, totalLines, startLine)
}

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.

The boundary check for startLine has a redundant first condition and produces misleading output for empty files. When totalLines == 0 and startLine == 1, neither condition triggers (0 > 0 is false, and totalLines > 0 is false), so execution continues and emits LINE_RANGE: 1-0 with zero content lines. The two conditions can be simplified to a single clear check, and empty files should be handled explicitly.

Suggestion:

Suggested change
if startLine-1 > totalLines || (totalLines > 0 && startLine-1 >= totalLines) {
return "", fmt.Errorf("file %q has only %d lines, requested range starting at %d", path, totalLines, startLine)
}
if startLine > totalLines {
return "", fmt.Errorf("file %q has only %d lines, requested range starting at %d", path, totalLines, startLine)
}

Comment on lines +389 to +402
func (service *ContextService) ready(ctx context.Context) error {
if service.bundle == nil {
return fmt.Errorf("bundle is required")
}
if service.repoDir == "" {
return fmt.Errorf("repository directory is required")
}
result := ValidationResult{Errors: make([]ValidationNotice, 0)}
validateFreshTarget(ctx, &result, service.bundle, service.repoDir, service.runner)
if len(result.Errors) > 0 {
return &ProtocolError{Code: "stale_bundle", Message: result.Errors[0].Message}
}
return nil
}

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.

ready() calls validateFreshTarget on every single Read/Find/Diff/Search operation. For scan-mode bundles, this reads and hashes every file from disk each time; for git-based targets, it runs ResolveTarget (git commands). In workflows that make many sequential context queries, this creates significant cumulative latency. Consider caching the validation result (e.g., validate once in the constructor or add a TTL-based cache) to avoid redundant I/O and git operations.

return true
}
for _, pattern := range patterns {
if matched, _ := filepath.Match(pattern, path); matched {

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.

filepath.Match uses OS-specific path separators. On Windows, it treats backslash as a separator, but bundle file paths consistently use forward slashes (as seen in cleanProtocolPath which normalizes via filepath.ToSlash). This means patterns like src/*.go will fail to match on Windows because filepath.Match won't interpret / as a separator. Use path.Match instead, which always uses forward slashes, or normalize both pattern and path before matching.

Suggestion:

Suggested change
if matched, _ := filepath.Match(pattern, path); matched {
if matched, _ := path.Match(pattern, path); matched {

Comment on lines +28 to +39
if f.FileFilter != nil && f.FileFilter.HasInclude() && f.FileFilter.IsUserIncluded(path) {
return model.ExcludeNone
}

extension := extensionFromPath(path)
if extension != "" && !allowedext.IsAllowedExt(extension) {
return model.ExcludeExtension
}
if allowedext.IsExcludedPath(path) {
return model.ExcludeDefaultPath
}
return model.ExcludeNone

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.

Bug: Include-list semantics are inverted. When HasInclude() is true but IsUserIncluded(path) returns false, execution falls through to extension/path checks and can still return ExcludeNone at line 39. This means files that do NOT match any user include pattern are still included in review — defeating the purpose of an explicit include list.

When a user configures an include list, the expected behavior is: only files matching the include patterns should be reviewed; everything else should be excluded. The current logic only short-circuits to ExcludeNone when a file IS included, but does not exclude files that fail the include check.

The same issue exists in ExcludeScanReason (line 54-56).

Suggestion:

Suggested change
if f.FileFilter != nil && f.FileFilter.HasInclude() && f.FileFilter.IsUserIncluded(path) {
return model.ExcludeNone
}
extension := extensionFromPath(path)
if extension != "" && !allowedext.IsAllowedExt(extension) {
return model.ExcludeExtension
}
if allowedext.IsExcludedPath(path) {
return model.ExcludeDefaultPath
}
return model.ExcludeNone
if f.FileFilter != nil && f.FileFilter.HasInclude() {
if f.FileFilter.IsUserIncluded(path) {
return model.ExcludeNone
}
return model.ExcludeUserRule
}
extension := extensionFromPath(path)
if extension != "" && !allowedext.IsAllowedExt(extension) {
return model.ExcludeExtension
}
if allowedext.IsExcludedPath(path) {
return model.ExcludeDefaultPath
}
return model.ExcludeNone

Comment on lines +42 to +43
// ExcludeScanReason preserves the native full-scan filter ordering.
func (f Filter) ExcludeScanReason(path string, isBinary bool) model.ExcludeReason {

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.

Bug: Inconsistent filter ordering between ExcludeReason and ExcludeScanReason. These two methods apply the same set of checks but in different order, which can produce different exclusion results for the same file:

  • ExcludeReason: user-exclude → user-include → extension → default-path
  • ExcludeScanReason: user-exclude → extension → user-include → default-path

For example, a file matching a user include pattern but having a disallowed extension would be included by ExcludeReason (include check short-circuits before extension) but excluded by ExcludeScanReason (extension check runs first). This inconsistency means diff-based review and full-scan review can classify the same file differently.

Consider aligning both methods to use the same canonical ordering, or documenting why the difference is intentional.

return "sha256:" + hex.EncodeToString(hasher.Sum(nil))
}

func writeHashFields(hasher hash.Hash, length [8]byte, fields ...[]byte) {

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.

The length array is passed by value to writeHashFields, meaning the function operates on a copy. While the current code happens to work correctly (because line 217 directly writes into the caller's own length before using it on line 218), this pattern is fragile and misleading. A future maintainer might reasonably assume that writeHashFields updates the caller's length buffer, leading to subtle hash-corruption bugs.

Consider passing a pointer to make the data flow explicit and prevent future mistakes:

func writeHashFields(hasher hash.Hash, length *[8]byte, fields ...[]byte) {
    for _, field := range fields {
        binary.BigEndian.PutUint64(length[:], uint64(len(field)))
        _, _ = hasher.Write(length[:])
        _, _ = hasher.Write(field)
    }
}

This also makes the regular-file branch at lines 216-218 clearly share the same buffer with writeHashFields, eliminating any ambiguity about whether state leaks between calls.

Comment on lines +98 to +105
if err != nil || hashFields(content) != file.ContentSHA256 {
addValidationError(
result,
"stale_bundle",
file.Path,
nil,
"scan file changed after bundle creation",
)

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 readTargetFile returns an error (e.g., file not found, permission denied, symlink escape), the code still reports "scan file changed after bundle creation" which is misleading. Additionally, hashFields(content) is called on nil/empty content when err != nil, which is harmless but unnecessary. Consider separating the error cases to provide more accurate diagnostics.

Suggestion:

Suggested change
if err != nil || hashFields(content) != file.ContentSHA256 {
addValidationError(
result,
"stale_bundle",
file.Path,
nil,
"scan file changed after bundle creation",
)
if err != nil {
addValidationError(
result,
"stale_bundle",
file.Path,
nil,
fmt.Sprintf("cannot verify scan file: %v", err),
)
} else if hashFields(content) != file.ContentSHA256 {
addValidationError(
result,
"stale_bundle",
file.Path,
nil,
"scan file changed after bundle creation",
)

Comment on lines +50 to +62
if bundle.SchemaVersion != BundleSchemaVersion ||
comments.SchemaVersion != CommentsSchemaVersion {
addValidationError(&result, "invalid_schema", "", nil, "unsupported protocol schema version")
}
if comments.BundleID != bundle.BundleID {
addValidationError(
&result,
"bundle_id_mismatch",
"",
nil,
"comments bundle_id does not match the review bundle",
)
}

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.

After detecting a schema version mismatch or bundle ID mismatch, the function continues to validate individual comments and target freshness. These are fundamental protocol-level errors that make all subsequent validation unreliable — for example, if the schema version differs, field semantics may have changed, and if the bundle ID doesn't match, the comments were produced for a different review entirely. Consider returning early after these critical errors to avoid producing misleading downstream diagnostics.

Suggestion:

Suggested change
if bundle.SchemaVersion != BundleSchemaVersion ||
comments.SchemaVersion != CommentsSchemaVersion {
addValidationError(&result, "invalid_schema", "", nil, "unsupported protocol schema version")
}
if comments.BundleID != bundle.BundleID {
addValidationError(
&result,
"bundle_id_mismatch",
"",
nil,
"comments bundle_id does not match the review bundle",
)
}
if bundle.SchemaVersion != BundleSchemaVersion ||
comments.SchemaVersion != CommentsSchemaVersion {
addValidationError(&result, "invalid_schema", "", nil, "unsupported protocol schema version")
result.Valid = false
return result
}
if comments.BundleID != bundle.BundleID {
addValidationError(
&result,
"bundle_id_mismatch",
"",
nil,
"comments bundle_id does not match the review bundle",
)
result.Valid = false
return result
}

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