Skip to content
Closed
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
5 changes: 5 additions & 0 deletions contracts/review-integration/v1/fixtures/start.fixture.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@
"review-readability",
"review-reliability"
],
"lens_bindings": [
{"lineage": "review-start-fixture", "target": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "lens": "review-risk", "order": 0, "repository": "/repository"}, {"lineage": "review-start-fixture", "target": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "lens": "review-resilience", "order": 1, "repository": "/repository"},
{"lineage": "review-start-fixture", "target": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "lens": "review-readability", "order": 2, "repository": "/repository"}, {"lineage": "review-start-fixture", "target": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "lens": "review-reliability", "order": 3, "repository": "/repository"}
],
"projection": "workspace",
"target_identity": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"changed_files": 1,
"changed_lines": 1,
"correction_budget": 1,
Expand Down
4 changes: 4 additions & 0 deletions contracts/review-integration/v1/fixtures/status.fixture.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@
{
"name": "order",
"value": "0"
},
{
"name": "repository",
"value": "/repository"
}
]
}
Expand Down
4 changes: 3 additions & 1 deletion contracts/review-integration/v1/schemas/start.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
"additionalProperties": false,
"dependentRequired": {
"target_mode": ["target_identity", "base_tree", "candidate_tree"],
"target_identity": ["target_mode", "base_tree", "candidate_tree"],
"base_tree": ["target_mode", "target_identity", "candidate_tree"],
"candidate_tree": ["target_mode", "target_identity", "base_tree"],
"candidate_diff": ["changed_path_manifest"],
Expand All @@ -31,7 +30,9 @@
"state",
"risk_level",
"selected_lenses",
"lens_bindings",
"projection",
"target_identity",
"changed_files",
"changed_lines",
"correction_budget",
Expand Down Expand Up @@ -68,6 +69,7 @@
"uniqueItems": true,
"items": {"enum": ["review-risk", "review-resilience", "review-readability", "review-reliability"]}
},
"lens_bindings": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["lineage", "target", "lens", "order"], "properties": {"lineage": {"type": "string", "minLength": 1}, "target": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, "lens": {"type": "string"}, "order": {"type": "integer", "minimum": 0}, "repository": {"type": "string", "minLength": 1}}}},
"projection": {"enum": ["workspace", "staged"]},
"target_mode": {"const": "base-workspace-overlay"},
"target_identity": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
Expand Down
12 changes: 9 additions & 3 deletions internal/assets/assets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -434,14 +434,20 @@ func TestReviewResultArtifactsPluginContract(t *testing.T) {
`"--order", String(binding.order)`,
`"--input", "-"`,
`"--preflight"`,
`GENTLE_AI_REVIEW_CWD`,
`repository?: string`,
`fields !== "lens,lineage,order,target" && fields !== "lens,lineage,order,repository,target"`,
`value.repository !== undefined`,
`value.repository.trim() === ""`,
`if (binding.repository !== undefined) return binding.repository`,
`process.env["GENTLE_AI_REVIEW_CWD"]`,
`return worktree || directory`,
`"tool.execute.before"`,
`output.args.background === true`,
`await preflightCapture(captureCwd(worktree, directory), parseBinding(output.args.prompt, output.args.subagent_type))`,
`await preflightCapture(captureCwd(binding, worktree, directory), binding)`,
`!BINDING.test(input.args.prompt)`,
`const lens = input.args.subagent_type`,
`const binding = parseBinding(input.args.prompt, lens)`,
`const cwd = captureCwd(worktree, directory)`,
`const cwd = captureCwd(binding, worktree, directory)`,
// The replayable payload is extracted exactly once before capture, so a
// capture failure preserves the extracted strict JSON, never the task
// envelope that `review capture-result --input` would reject on replay.
Expand Down
19 changes: 12 additions & 7 deletions internal/assets/opencode/plugins/review-result-artifacts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Plugin } from "@opencode-ai/plugin"
import { spawn } from "node:child_process"
import { isAbsolute } from "node:path"

const REVIEW_AGENTS = new Set(["review-risk", "review-resilience", "review-readability", "review-reliability"])
const BINDING = /^GENTLE_AI_REVIEW_BINDING (\{[^\n]+\})(?:\n|$)/
Expand All @@ -11,6 +12,7 @@ type ReviewBinding = {
target: string
lens: string
order: number
repository?: string
}

function parseBinding(prompt: unknown, lens: string): ReviewBinding {
Expand All @@ -28,10 +30,12 @@ function parseBinding(prompt: unknown, lens: string): ReviewBinding {
}
const value = binding as Record<string, unknown>
const fields = Object.keys(value).sort().join(",")
if (fields !== "lens,lineage,order,target" ||
if ((fields !== "lens,lineage,order,target" && fields !== "lens,lineage,order,repository,target") ||
typeof value.lineage !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value.lineage) ||
typeof value.target !== "string" || !/^sha256:[a-f0-9]{64}$/.test(value.target) ||
value.lens !== lens || !Number.isSafeInteger(value.order) || (value.order as number) < 0) {
value.lens !== lens || !Number.isSafeInteger(value.order) || (value.order as number) < 0 ||
(value.repository !== undefined &&
(typeof value.repository !== "string" || value.repository.trim() === "" || !isAbsolute(value.repository)))) {
throw new Error("review task binding does not match the selected lens")
}
return value as ReviewBinding
Expand All @@ -51,7 +55,8 @@ function reviewerResult(output: unknown): string {
return envelope[1]
}

function captureCwd(worktree: string | undefined, directory: string): string {
function captureCwd(binding: ReviewBinding, worktree: string | undefined, directory: string): string {
if (binding.repository !== undefined) return binding.repository
const override = process.env["GENTLE_AI_REVIEW_CWD"]
if (typeof override === "string" && override.trim() !== "") return override.trim()
return worktree || directory
Expand Down Expand Up @@ -102,8 +107,7 @@ async function preflightCapture(cwd: string, binding: ReviewBinding): Promise<vo
throw new Error(
`review capture preflight failed for lens ${binding.lens} under ${cwd}: ${errorMessage(cause)}. ` +
`The reviewer was not launched, so its exactly-once invocation is preserved. ` +
`If lineage ${binding.lineage} was started in a different repository (for example a nested one), ` +
`set GENTLE_AI_REVIEW_CWD to that repository and relaunch the lens.`,
`Use the canonical repository emitted by START for lineage ${binding.lineage} and relaunch the lens.`,
)
}
}
Expand Down Expand Up @@ -164,14 +168,15 @@ const ReviewResultArtifactsPlugin: Plugin = async ({ directory, worktree }) => (
if (output.args.background === true) {
throw new Error("bound review tasks must run in the foreground for native result capture")
}
await preflightCapture(captureCwd(worktree, directory), parseBinding(output.args.prompt, output.args.subagent_type))
const binding = parseBinding(output.args.prompt, output.args.subagent_type)
await preflightCapture(captureCwd(binding, worktree, directory), binding)
},
"tool.execute.after": async (input, output) => {
if (input.tool !== "task" || typeof input.args?.subagent_type !== "string" || !REVIEW_AGENTS.has(input.args.subagent_type)) return
if (typeof input.args.prompt !== "string" || !BINDING.test(input.args.prompt)) return
const lens = input.args.subagent_type
const binding = parseBinding(input.args.prompt, lens)
const cwd = captureCwd(worktree, directory)
const cwd = captureCwd(binding, worktree, directory)
// Extract the replayable payload exactly once, BEFORE capture: recovery
// re-runs `review capture-result --input <preserved file>`, whose strict
// decoder rejects the task envelope, so a capture failure must preserve
Expand Down
2 changes: 1 addition & 1 deletion internal/assets/skills/_shared/review-ledger-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Parent orchestrator and native CLI only. Never pass this contract to a reviewer,

Call `gentle-ai review start` once. The native facade discovers the repository root and untracked scope, derives the immutable target, selects zero lenses for low risk, one focus lens for standard risk, or canonical 4R for high risk, and freezes the original line count, tier, and correction budget `min(200, ceil(original_changed_lines / 2))`. Goldens stay in snapshot identity but not that count. Correction and compatible base advance never recalculate risk or open review.

Run each selected lens once in the foreground. Prefix its task prompt with `GENTLE_AI_REVIEW_BINDING {"lineage":"<lineage>","target":"<target_identity>","lens":"<lens>","order":<selected_order>}` from START. Capture its JSON with `gentle-ai review capture-result --cwd <repo> --lineage <lineage> --target <target_identity> --lens <lens> --order <selected_order> --input <file-or-stdin>`; OpenCode's managed hook does this automatically. Write each emitted manifest to its own file and pass every file to FINALIZE in selected-lens order as repeated `--result-artifact-file <path>` arguments, never raw `--result` files. Use BOM-less UTF-8 on Windows PowerShell 5.1. The POSIX inline `--result-artifact '<manifest-json>'` form remains compatible. Native Go validates, canonicalizes, persists, hashes, reopens, and binds results; models never construct canonical bytes or hashes. Freeze merged findings and classify every severe finding. Only `introduced`, `behavior-activated`, or `worsened` with changed-hunk, candidate-created-path, differential-test, or before/after proof may block. Route `pre-existing` and `base-only` to follow-ups; `unknown` escalates. WARNING/SUGGESTION remain `info`. Deterministic blockers need no refuter; all inferential blockers share one read-only refuter batch. Judgment Day uses two independent judges instead.
Run each selected lens once in the foreground. For negotiated START, construct `GENTLE_AI_REVIEW_BINDING` directly from the matching lens binding's immutable `lineage`, `target`, `lens`, and `order`; use its `repository` as `--cwd` when present. Repository is optional in the existing v1 reader contract: when absent, the OpenCode hook falls back to `GENTLE_AI_REVIEW_CWD`, then its worktree or directory. The legacy four-field binding remains accepted. When repository is emitted, capture its JSON with `gentle-ai review capture-result --cwd <repository> --lineage <lineage> --target <target_identity> --lens <lens> --order <selected_order> --input <file-or-stdin>`; otherwise, resolve `--cwd` through that fallback. OpenCode's managed hook does this automatically. Write each emitted manifest to its own file and pass every file to FINALIZE in selected-lens order as repeated `--result-artifact-file <path>` arguments, never raw `--result` files. Use BOM-less UTF-8 on Windows PowerShell 5.1. The POSIX inline `--result-artifact '<manifest-json>'` form remains compatible. Native Go validates, canonicalizes, persists, hashes, reopens, and binds results only after checking the bound repository's Git common-dir, lineage, target, lens, and order before launch; models never construct canonical bytes or hashes. Freeze merged findings and classify every severe finding. Only `introduced`, `behavior-activated`, or `worsened` with changed-hunk, candidate-created-path, differential-test, or before/after proof may block. Route `pre-existing` and `base-only` to follow-ups; `unknown` escalates. WARNING/SUGGESTION remain `info`. Deterministic blockers need no refuter; all inferential blockers share one read-only refuter batch. Judgment Day uses two independent judges instead.

Before each lens, append the exact immutable candidate diff and changed-path manifest from START; if unavailable, stop.

Expand Down
22 changes: 14 additions & 8 deletions internal/cli/review_facade.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,20 @@ type ReviewFacadeStartResult struct {
}

// ReviewFacadeLensBinding pairs one selected lens with its frozen zero-based
// order so orchestrators build capture bindings exclusively from START output.
// order. Negotiated START enriches each binding with its immutable lineage and
// target; repository remains an optional v1 routing hint.
type ReviewFacadeLensBinding struct {
Lens string `json:"lens"`
Order int `json:"order"`
Lineage string `json:"lineage,omitempty"`
Target string `json:"target,omitempty"`
Lens string `json:"lens"`
Order int `json:"order"`
Repository string `json:"repository,omitempty"`
}

func facadeLensBindings(lenses []string) []ReviewFacadeLensBinding {
func facadeLensBindings(repository string, lenses []string) []ReviewFacadeLensBinding {
bindings := make([]ReviewFacadeLensBinding, len(lenses))
for order, lens := range lenses {
bindings[order] = ReviewFacadeLensBinding{Lens: lens, Order: order}
bindings[order] = ReviewFacadeLensBinding{Lens: lens, Order: order, Repository: repository}
}
return bindings
}
Expand Down Expand Up @@ -466,7 +470,7 @@ func runReviewStatus(ctx context.Context, args []string, stdout io.Writer) error
}
}
}
transition := newReviewNextTransition(result, native.SelectedLenses, artifacts, evidenceAvailable, artifactErr, reviewNextTransitionInput{Gate: reviewtransaction.GateKind(*gate), Successor: *recoverySuccessor, Reason: *recoveryReason, Actor: *recoveryActor, Authorization: *recoveryAuthorization})
transition := newReviewNextTransition(result, native.SelectedLenses, artifacts, evidenceAvailable, artifactErr, reviewNextTransitionInput{Gate: reviewtransaction.GateKind(*gate), Successor: *recoverySuccessor, Reason: *recoveryReason, Actor: *recoveryActor, Authorization: *recoveryAuthorization, Repository: root})
result.NextTransition = &transition
}
if err := result.Validate(); err != nil {
Expand Down Expand Up @@ -865,6 +869,7 @@ func runReviewFacadeStart(ctx context.Context, args []string, stdout io.Writer)
}
if negotiated && requestedContextErr == nil {
preview := reviewFacadeStartResultFor(reviewtransaction.CompactStartCreated, len(state.SelectedLenses) > 0, state)
preview.LensBindings = facadeLensBindings(root, state.SelectedLenses)
if _, previewErr := newReviewIntegrationStartResult(preview, assessment, state.InitialSnapshot.Kind, requestedFrozenContext); previewErr != nil {
requestedContextErr = &reviewStartContextError{LineageID: state.LineageID, Cause: previewErr}
}
Expand Down Expand Up @@ -893,6 +898,7 @@ func runReviewFacadeStart(ctx context.Context, args []string, stdout io.Writer)
return fmt.Errorf("classify authoritative negotiated START target: %w", err)
}
}
legacyResult.LensBindings = facadeLensBindings(root, authority.SelectedLenses)
var frozenContext *reviewtransaction.FrozenCandidateContext
if len(authority.SelectedLenses) > 0 {
if authority.InitialSnapshot.Identity == state.InitialSnapshot.Identity && requestedFrozenContext != nil {
Expand Down Expand Up @@ -920,7 +926,7 @@ func reviewFacadeStartResultFor(action reviewtransaction.CompactStartAction, len
result := ReviewFacadeStartResult{
Operation: "review/start", Action: string(action), LensesRequired: lensesRequired,
LineageID: authority.LineageID, State: authority.State, RiskLevel: authority.RiskLevel,
SelectedLenses: append([]string{}, authority.SelectedLenses...), LensBindings: facadeLensBindings(authority.SelectedLenses),
SelectedLenses: append([]string{}, authority.SelectedLenses...), LensBindings: facadeLensBindings("", authority.SelectedLenses),
Projection: facadeProjection(authority.InitialSnapshot.Projection),
ChangedFiles: len(authority.InitialSnapshot.Paths), TargetIdentity: authority.InitialSnapshot.Identity,
ChangedLines: authority.OriginalChangedLines, CorrectionBudget: authority.CorrectionBudget,
Expand Down Expand Up @@ -2164,7 +2170,7 @@ func encodeCompactFacadeFinalize(stdout io.Writer, negotiated, actionEligibility
var transition *ReviewNextTransition
if nextTransition {
artifacts, artifactErr := discoverCapturedReviewerArtifacts(store.Dir, state)
value := reviewFinalizeNextTransition(state, revision, artifacts, artifactErr)
value := reviewFinalizeNextTransition(state, revision, artifacts, artifactErr, store.Repository())
transition = &value
}
result := ReviewFacadeFinalizeResult{
Expand Down
37 changes: 37 additions & 0 deletions internal/cli/review_facade_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,43 @@ func TestReviewFacadeStartUnnegotiatedJSONFieldSetRemainsCompatible(t *testing.T
if !reflect.DeepEqual(got, want) {
t.Fatalf("unnegotiated start fields = %v, want %v", got, want)
}
var bindings []map[string]json.RawMessage
if err := json.Unmarshal(fields["lens_bindings"], &bindings); err != nil {
t.Fatal(err)
}
for _, binding := range bindings {
if len(binding) != 2 || binding["lens"] == nil || binding["order"] == nil {
t.Fatalf("unnegotiated lens binding fields = %v, want [lens order]", binding)
}
}
}

func TestReviewFacadeStartLensBindingsCarryCanonicalRepository(t *testing.T) {
repo := initReviewCLIRepo(t)
nested := filepath.Join(repo, "nested")
if err := os.Mkdir(nested, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repo, "tracked.txt"), []byte("candidate\n"), 0o644); err != nil {
t.Fatal(err)
}
root, err := (reviewtransaction.SnapshotBuilder{Repo: nested}).ResolveRepositoryRoot(context.Background())
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
if err := RunReviewFacadeStart([]string{"--cwd", nested, "--contract", ReviewIntegrationContractV1}, &output); err != nil {
t.Fatal(err)
}
started := decodeNegotiatedReviewStart(t, output.Bytes())
if len(started.LensBindings) == 0 {
t.Fatal("START emitted no lens bindings")
}
for _, binding := range started.LensBindings {
if binding.Repository != root {
t.Fatalf("lens binding repository = %q, want canonical root %q", binding.Repository, root)
}
}
}

func TestReviewFacadeFinalizeReceiptPublicationFailureIsExactlyReplayable(t *testing.T) {
Expand Down
17 changes: 14 additions & 3 deletions internal/cli/review_incident_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,18 @@ func TestReviewCaptureResultNestedRepositoryFailsActionablyAndStaysRetriable(t *
if err := os.WriteFile(filepath.Join(child, "tracked.txt"), []byte("candidate\n"), 0o644); err != nil {
t.Fatal(err)
}
started := startFacadeReview(t, child)
started := runNegotiatedReviewStart(t, child, "nested-repository-capture")
if len(started.LensBindings) == 0 {
t.Fatal("START emitted no lens bindings")
}
binding := started.LensBindings[0]
childRoot, err := (reviewtransaction.SnapshotBuilder{Repo: child}).ResolveRepositoryRoot(context.Background())
if err != nil {
t.Fatal(err)
}
if binding.Repository != childRoot {
t.Fatalf("bound repository = %q, want %q", binding.Repository, childRoot)
}
store, _ := reviewtransaction.CompactAuthoritativeStore(context.Background(), child, started.LineageID)
record, err := store.Load()
if err != nil {
Expand All @@ -66,7 +77,7 @@ func TestReviewCaptureResultNestedRepositoryFailsActionablyAndStaysRetriable(t *
args := func(cwd string, rest ...string) []string {
return append([]string{
"--cwd", cwd, "--lineage", started.LineageID, "--target", record.State.InitialSnapshot.Identity,
"--lens", record.State.SelectedLenses[0], "--order", "0",
"--lens", binding.Lens, "--order", "0",
}, rest...)
}
parentRoot, err := (reviewtransaction.SnapshotBuilder{Repo: parent}).ResolveRepositoryRoot(context.Background())
Expand All @@ -85,7 +96,7 @@ func TestReviewCaptureResultNestedRepositoryFailsActionablyAndStaysRetriable(t *
// The failed parent-repository capture must not consume the exactly-once
// native lens slot: the same capture succeeds from the reviewing repository.
var output bytes.Buffer
if err := RunReviewCaptureResult(args(child, "--input", input), &output); err != nil {
if err := RunReviewCaptureResult(args(binding.Repository, "--input", input), &output); err != nil {
t.Fatalf("retry from reviewing repository failed: %v", err)
}
manifest := strings.TrimSpace(output.String())
Expand Down
Loading
Loading