diff --git a/.agent/docs/architecture/supported-workflows.md b/.agent/docs/architecture/supported-workflows.md index d7d8e155..5d19ce44 100644 --- a/.agent/docs/architecture/supported-workflows.md +++ b/.agent/docs/architecture/supported-workflows.md @@ -288,9 +288,11 @@ workflow accepts a pull request number, confirms the target is an open PR, and requires latest trusted review synthesis from the authenticated Sepo actor for the current reviewed-head marker before it runs an approval agent. Normal runs require that synthesis to be `SHIP`; orchestrated review `HUMAN_DECISION` -handoffs may also run the agent as a decision gate for non-`SHIP` verdicts. The -agent runs with read-approved permissions and returns structured JSON with a -verdict, reason, optional follow-up context, and `inspected_head_sha`. +handoffs may also run the agent as a decision gate for non-`SHIP` verdicts, and +orchestrated `MINOR_ISSUES` reviews that recommend `NO_AUTOMATED_ACTION` may run +the gate when no required branch-change work remains. The agent runs with +read-approved permissions and returns structured JSON with a verdict, reason, +optional follow-up context, and `inspected_head_sha`. Deterministic resolver code is the only part that can submit or record the approval. It rereads the current PR head, rechecks trusted current-head review @@ -299,13 +301,16 @@ unless both `AGENT_ALLOW_SELF_APPROVE=true` and `AGENT_ALLOW_SELF_MERGE=true` are enabled, parses the agent verdict, and approves only when the expected, current, and inspected head SHAs match. Normal handoffs require trusted current-head `SHIP` review synthesis; orchestrated review `HUMAN_DECISION` -handoffs also trust the matching current-head synthesis as the decision gate. -Non-approval outcomes post a compact PR status comment. In full +handoffs also trust the matching current-head synthesis as the decision gate, +and `MINOR_ISSUES` plus `NO_AUTOMATED_ACTION` is trusted only when the matching +synthesis action items show no required branch-change work. Non-approval +outcomes post a compact PR status comment. In full self-governance mode, same-actor approvals are recorded as a current-head self-approval status comment rather than a GitHub review approval. In -orchestrated chains, `SHIP` review synthesis and review syntheses that recommend -`HUMAN_DECISION` can hand off to `agent-self-approve`; non-`SHIP` -`HUMAN_DECISION` runs let self-approval approve, request changes, or block. A +orchestrated chains, `SHIP` review synthesis, review syntheses that recommend +`HUMAN_DECISION`, and no-required-branch-work `MINOR_ISSUES` syntheses that +recommend `NO_AUTOMATED_ACTION` can hand off to `agent-self-approve`; non-`SHIP` +handoffs let self-approval approve, request changes, or block. A self-approval `REQUEST_CHANGES` result can hand off to `fix-pr` with the approval agent's handoff context. Self-approval status comments are upserted by marker against comments authored by the authenticated Sepo actor, and result diff --git a/.agent/docs/technical-details/agent-orchestrator.md b/.agent/docs/technical-details/agent-orchestrator.md index 6eb12da7..fa0ddc99 100644 --- a/.agent/docs/technical-details/agent-orchestrator.md +++ b/.agent/docs/technical-details/agent-orchestrator.md @@ -24,9 +24,9 @@ stateDiagram-v2 Implement --> Review: success + PR created Implement --> Stop: failed or no PR - Review --> SelfApprove: SHIP or HUMAN_DECISION + AGENT_ALLOW_SELF_APPROVE=true - Review --> FixPR: MINOR_ISSUES / NEEDS_REWORK / CHANGES_REQUESTED without HUMAN_DECISION - Review --> Stop: SHIP or HUMAN_DECISION + self-approval disabled + Review --> SelfApprove: SHIP / HUMAN_DECISION / MINOR_ISSUES no required branch work + AGENT_ALLOW_SELF_APPROVE=true + Review --> FixPR: MINOR_ISSUES / NEEDS_REWORK / CHANGES_REQUESTED with required branch work + Review --> Stop: SHIP / HUMAN_DECISION / no required branch work + self-approval disabled Review --> Stop: failed or unsupported verdict SelfApprove --> FixPR: REQUEST_CHANGES @@ -67,6 +67,7 @@ When an action-originated handoff is used, the orchestrator also accepts: - source action - source conclusion - source recommended next step, when the source is review synthesis +- whether the source review synthesis has required branch-change action items - target issue or pull request number - next target number when implementation opened a pull request - source workflow run ID for duplicate-dispatch detection @@ -176,7 +177,7 @@ envelopes use the planner path when enabled. In `heuristics` mode, action-originated handoff decisions still use the fixed transition policy and round budget checks. -Review-originated `fix-pr` handoffs carry explicit task context when available. The review dispatcher derives it from the latest review synthesis action items, and heuristic mode falls back to a conservative instruction to address only unresolved review synthesis action items while ignoring optional INFO notes and metadata-only polish. When a review synthesis recommends `HUMAN_DECISION`, self-approval-enabled orchestration routes to `agent-self-approve` instead of `fix-pr` or a human stop; self-approval then decides whether to approve, request changes, or block. Manual PR `/orchestrate` starts with a `CHANGES_REQUESTED` review decision use separate context that tells `fix-pr` to address the latest unresolved requested-change review comments instead of the review-synthesis fallback. Self-approval `REQUEST_CHANGES` handoffs preserve the approval agent's handoff context as the `fix-pr` task. Self-approval `APPROVED` handoffs dispatch `agent-self-merge` only when `AGENT_ALLOW_SELF_MERGE=true`. +Review-originated `fix-pr` handoffs carry explicit task context when available. The review dispatcher derives it from the latest review synthesis action items, ignores sentinel no-op items such as "No required branch-change work remains", and heuristic mode falls back to a conservative instruction to address only unresolved review synthesis action items while ignoring optional INFO notes and metadata-only polish. When a review synthesis recommends `HUMAN_DECISION`, self-approval-enabled orchestration routes to `agent-self-approve` instead of `fix-pr` or a human stop; self-approval then decides whether to approve, request changes, or block. `NO_AUTOMATED_ACTION` from review means no more branch mutation/review loop is useful, not necessarily no approval gate: when the current review verdict is `MINOR_ISSUES`, no required branch-change work remains, and `AGENT_ALLOW_SELF_APPROVE=true`, orchestration may hand off to `agent-self-approve`. If self-approval is disabled, the orchestrator stops cleanly, and blocking verdicts or required branch-change action items still prevent that shortcut. Manual PR `/orchestrate` starts with a `CHANGES_REQUESTED` review decision use separate context that tells `fix-pr` to address the latest unresolved requested-change review comments instead of the review-synthesis fallback. Self-approval `REQUEST_CHANGES` handoffs preserve the approval agent's handoff context as the `fix-pr` task. Self-approval `APPROVED` handoffs dispatch `agent-self-merge` only when `AGENT_ALLOW_SELF_MERGE=true`. In `agent` mode, the orchestrator first runs a scoped planner prompt through the same resolved-provider runtime used by other agent actions. The planner has its own `orchestrator` route and `planner` lane, so session continuation is separate from implement, review, and fix-pr sessions. The planner runs with `approve-all` tool permission so it can gather current GitHub and repository context in non-interactive workflows. It still receives read-only repository memory, selected read-only rubrics, the handoff envelope, any source handoff context, and original request, and returns JSON describing whether to stop, block, delegate a child issue, or hand off. For blocked decisions, the planner may return `user_message` or `clarification_request` to ask for missing context in the visible stop comment. For handoffs, the planner may also return `handoff_context`: explicit, action-oriented instructions for the next workflow. When the next action is `fix-pr`, the dispatcher passes that context into `agent-fix-pr.yml`, and the fix-pr prompt treats it as the selected task and constraints for the automated fix pass. The workflow uses the runtime preflight CLI to skip this planner when the max-round budget is already exhausted or the initial requester lacks delegated-route capability, and the runtime still validates planner JSON against the fixed transition policy, the issue-only direct-implement rule, and max-round budget before dispatching anything. diff --git a/.agent/src/__tests__/dispatch-agent-orchestrator-cli.test.ts b/.agent/src/__tests__/dispatch-agent-orchestrator-cli.test.ts index 07c6200f..a2834881 100644 --- a/.agent/src/__tests__/dispatch-agent-orchestrator-cli.test.ts +++ b/.agent/src/__tests__/dispatch-agent-orchestrator-cli.test.ts @@ -105,6 +105,68 @@ exit 1 const payload = JSON.parse(readFileSync(payloadPath, "utf8")); assert.equal(payload.inputs.source_conclusion, "minor_issues"); assert.equal(payload.inputs.source_recommended_next_step, "human_decision"); + assert.equal(payload.inputs.source_required_branch_work, "true"); + assert.equal(payload.inputs.source_handoff_context, ""); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("dispatch-agent-orchestrator forwards no required branch-work review state", () => { + const tempDir = mkdtempSync(join(tmpdir(), "agent-dispatch-orchestrator-")); + try { + const payloadPath = join(tempDir, "dispatch.json"); + const responsePath = join(tempDir, "response.md"); + writeFileSync( + responsePath, + [ + "## Recommended Next Step", + "NO_AUTOMATED_ACTION: no unresolved actionable work remains.", + "", + "## Final Verdict", + "MINOR_ISSUES", + "", + "## Action Items", + "- [ ] No required branch-change work remains.", + ].join("\n"), + "utf8", + ); + writeFileSync(join(tempDir, "gh"), `#!/usr/bin/env bash +set -euo pipefail +if [ "\${1-}" = "api" ] && [ "\${2-}" = "-X" ] && [ "\${3-}" = "POST" ]; then + cat > "$FAKE_DISPATCH_PAYLOAD" + exit 0 +fi +printf 'unexpected gh args: %s\\n' "$*" >&2 +exit 1 +`, { encoding: "utf8", mode: 0o755 }); + + const result = spawnSync("node", [".agent/dist/cli/dispatch-agent-orchestrator.js"], { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${tempDir}:${process.env.PATH || ""}`, + FAKE_DISPATCH_PAYLOAD: payloadPath, + GITHUB_REPOSITORY: "self-evolving/repo", + DEFAULT_BRANCH: "main", + SOURCE_ACTION: "review", + RESPONSE_FILE: responsePath, + TARGET_KIND: "pull_request", + TARGET_NUMBER: "30", + REQUESTED_BY: "lolipopshock", + REQUEST_TEXT: "@sepo-agent /orchestrate", + AUTOMATION_MODE: "heuristics", + ORCHESTRATION_ENABLED: "true", + }, + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.ok(existsSync(payloadPath)); + const payload = JSON.parse(readFileSync(payloadPath, "utf8")); + assert.equal(payload.inputs.source_conclusion, "minor_issues"); + assert.equal(payload.inputs.source_recommended_next_step, "no_automated_action"); + assert.equal(payload.inputs.source_required_branch_work, "false"); assert.equal(payload.inputs.source_handoff_context, ""); } finally { rmSync(tempDir, { recursive: true, force: true }); diff --git a/.agent/src/__tests__/envelope.test.ts b/.agent/src/__tests__/envelope.test.ts index fa880c49..f3f6bddf 100644 --- a/.agent/src/__tests__/envelope.test.ts +++ b/.agent/src/__tests__/envelope.test.ts @@ -1287,9 +1287,12 @@ test("execution workflows expose automation handoff inputs", () => { assert.match(orchestratorWorkflow, /base_branch:/); assert.match(orchestratorWorkflow, /base_pr:/); assert.match(orchestratorWorkflow, /source_handoff_context:/); + assert.match(orchestratorWorkflow, /source_required_branch_work:/); assert.match(orchestratorWorkflow, /AGENT_COLLAPSE_OLD_REVIEWS:\s*\$\{\{ vars\.AGENT_COLLAPSE_OLD_REVIEWS \}\}/); assert.match(orchestratorWorkflow, /BASE_BRANCH:\s*\$\{\{ inputs\.base_branch \}\}/); + assert.match(orchestratorWorkflow, /SOURCE_REQUIRED_BRANCH_WORK:\s*\$\{\{ inputs\.source_required_branch_work \}\}/); assert.match(orchestratorWorkflow, /SOURCE_HANDOFF_CONTEXT:\s*\$\{\{ inputs\.source_handoff_context \}\}/); + assert.match(orchestratorWorkflow, /ORCHESTRATOR_SOURCE_REQUIRED_BRANCH_WORK:\s*\$\{\{ inputs\.source_required_branch_work \}\}/); assert.match(orchestratorWorkflow, /ORCHESTRATOR_SOURCE_HANDOFF_CONTEXT:\s*\$\{\{ inputs\.source_handoff_context \}\}/); assert.match(orchestrateHandoffCli, /resolveEffectiveBaseInputs/); assert.match(orchestrateHandoffCli, /baseBranch:\s*decision\.baseBranch \|\| baseBranch/); @@ -1313,6 +1316,7 @@ test("execution workflows expose automation handoff inputs", () => { assert.match(fixPrPrompt, /\$\{ORCHESTRATOR_CONTEXT\}/); assert.match(orchestratorPrompt, /"handoff_context"/); assert.match(orchestratorPrompt, /ORCHESTRATOR_SOURCE_HANDOFF_CONTEXT/); + assert.match(orchestratorPrompt, /ORCHESTRATOR_SOURCE_REQUIRED_BRANCH_WORK/); assert.match(orchestratorPrompt, /ORCHESTRATOR_SELF_APPROVE_ENABLED/); assert.match(orchestratorPrompt, /ORCHESTRATOR_SELF_MERGE_ENABLED/); assert.match(orchestratorPrompt, /"user_message"/); @@ -1335,12 +1339,18 @@ test("orchestrator source handoff context is renderable in planner prompts", () const runSource = readRepoFile(".agent/src/run.ts"); const orchestratorPrompt = readRepoFile(".github/prompts/agent-orchestrator.md"); const sourceContextName = "ORCHESTRATOR_SOURCE_HANDOFF_CONTEXT"; + const sourceRequiredWorkName = "ORCHESTRATOR_SOURCE_REQUIRED_BRANCH_WORK"; assert.match(orchestratorPrompt, /\$\{ORCHESTRATOR_SOURCE_HANDOFF_CONTEXT\}/); + assert.match(orchestratorPrompt, /\$\{ORCHESTRATOR_SOURCE_REQUIRED_BRANCH_WORK\}/); assert.ok( readSupplementalPromptVarNames(runSource).has(sourceContextName), `${sourceContextName} must be allowlisted for runtime prompt rendering`, ); + assert.ok( + readSupplementalPromptVarNames(runSource).has(sourceRequiredWorkName), + `${sourceRequiredWorkName} must be allowlisted for runtime prompt rendering`, + ); }); test("workflow docs cover hosted auth and self-hosting paths", () => { diff --git a/.agent/src/__tests__/handoff.test.ts b/.agent/src/__tests__/handoff.test.ts index 486afbae..ebf3928b 100644 --- a/.agent/src/__tests__/handoff.test.ts +++ b/.agent/src/__tests__/handoff.test.ts @@ -5,8 +5,10 @@ import { buildReviewFixPrHandoffContext, buildHandoffDedupeKey, buildHandoffMarker, + deriveReviewRequiredBranchWork, decideHandoff, defaultFixPrHandoffContext, + extractRequiredReviewActionItems, extractReviewConclusion, extractReviewRecommendedNextStep, extractReviewActionItems, @@ -549,6 +551,77 @@ test("review HUMAN_DECISION stops when self-approval is disabled", () => { assert.match(decision.reason, /HUMAN_DECISION/); }); +test("review NO_AUTOMATED_ACTION dispatches self-approval for minor issues with no required branch work", () => { + const decision = decideHandoff({ + automationMode: "heuristics", + sourceAction: "review", + sourceConclusion: "MINOR_ISSUES", + sourceRecommendedNextStep: "NO_AUTOMATED_ACTION", + sourceRequiredBranchWork: "false", + targetNumber: "99", + currentRound: 2, + maxRounds: 5, + allowSelfApprove: true, + }); + + assert.equal(decision.decision, "dispatch"); + assert.equal(decision.nextAction, "agent-self-approve"); + assert.equal(decision.targetNumber, "99"); + assert.match(decision.reason, /no required branch-change work/); +}); + +test("review NO_AUTOMATED_ACTION stops cleanly when self-approval is disabled", () => { + const decision = decideHandoff({ + automationMode: "heuristics", + sourceAction: "review", + sourceConclusion: "MINOR_ISSUES", + sourceRecommendedNextStep: "NO_AUTOMATED_ACTION", + sourceRequiredBranchWork: "false", + targetNumber: "99", + currentRound: 2, + maxRounds: 5, + allowSelfApprove: false, + }); + + assert.equal(decision.decision, "stop"); + assert.equal(decision.nextAction, undefined); + assert.match(decision.reason, /self-approval disabled/); +}); + +test("review NO_AUTOMATED_ACTION does not self-approve blocking or required-work outcomes", () => { + const requiredWork = decideHandoff({ + automationMode: "heuristics", + sourceAction: "review", + sourceConclusion: "MINOR_ISSUES", + sourceRecommendedNextStep: "NO_AUTOMATED_ACTION", + sourceRequiredBranchWork: "true", + sourceHandoffContext: "Address the required review action item.", + targetNumber: "99", + currentRound: 2, + maxRounds: 5, + allowSelfApprove: true, + }); + assert.equal(requiredWork.decision, "dispatch"); + assert.equal(requiredWork.nextAction, "fix-pr"); + assert.equal(requiredWork.handoffContext, "Address the required review action item."); + assert.match(requiredWork.reason, /require branch changes/); + + const blocking = decideHandoff({ + automationMode: "heuristics", + sourceAction: "review", + sourceConclusion: "NEEDS_REWORK", + sourceRecommendedNextStep: "NO_AUTOMATED_ACTION", + sourceRequiredBranchWork: "false", + targetNumber: "99", + currentRound: 2, + maxRounds: 5, + allowSelfApprove: true, + }); + assert.equal(blocking.decision, "stop"); + assert.equal(blocking.nextAction, undefined); + assert.match(blocking.reason, /NO_AUTOMATED_ACTION/); +}); + test("agent mode validates review HUMAN_DECISION self-approval handoff", () => { const allowed = decideHandoff({ automationMode: "agent", @@ -583,6 +656,46 @@ test("agent mode validates review HUMAN_DECISION self-approval handoff", () => { assert.match(wrong.reason, /policy only allows agent-self-approve/); }); +test("agent mode validates review NO_AUTOMATED_ACTION self-approval handoff", () => { + const allowed = decideHandoff({ + automationMode: "agent", + sourceAction: "review", + sourceConclusion: "MINOR_ISSUES", + sourceRecommendedNextStep: "NO_AUTOMATED_ACTION", + sourceRequiredBranchWork: "false", + targetNumber: "99", + currentRound: 2, + maxRounds: 5, + allowSelfApprove: true, + plannerDecision: { + decision: "handoff", + nextAction: "agent-self-approve", + reason: "No required branch-change work remains and self-approval is enabled.", + }, + }); + assert.equal(allowed.decision, "dispatch"); + assert.equal(allowed.nextAction, "agent-self-approve"); + + const requiredWork = decideHandoff({ + automationMode: "agent", + sourceAction: "review", + sourceConclusion: "MINOR_ISSUES", + sourceRecommendedNextStep: "NO_AUTOMATED_ACTION", + sourceRequiredBranchWork: "true", + targetNumber: "99", + currentRound: 2, + maxRounds: 5, + allowSelfApprove: true, + plannerDecision: { + decision: "handoff", + nextAction: "agent-self-approve", + reason: "Try to approve despite required branch work.", + }, + }); + assert.equal(requiredWork.decision, "stop"); + assert.match(requiredWork.reason, /policy only allows fix-pr/); +}); + test("review fix-pr handoffs preserve derived source context", () => { const decision = decideHandoff({ automationMode: "heuristics", @@ -945,3 +1058,24 @@ test("review fix-pr context extracts unchecked review synthesis action items", ( ].join("\n"), ); }); + +test("review required branch-work extraction ignores no-op action item sentinels", () => { + const noWork = [ + "## Action Items", + "- [ ] No required branch-change work remains.", + ].join("\n"); + assert.deepEqual(extractReviewActionItems(noWork), ["No required branch-change work remains."]); + assert.deepEqual(extractRequiredReviewActionItems(noWork), []); + assert.equal(deriveReviewRequiredBranchWork(noWork), "false"); + + const required = [ + "## Action Items", + "- [ ] Update the self-approval provenance check.", + "- [ ] No required branch-change work remains.", + ].join("\n"); + assert.deepEqual(extractRequiredReviewActionItems(required), [ + "Update the self-approval provenance check.", + ]); + assert.equal(deriveReviewRequiredBranchWork(required), "true"); + assert.equal(deriveReviewRequiredBranchWork("## Review\nNo action section."), "unknown"); +}); diff --git a/.agent/src/__tests__/orchestrate-handoff-cli.test.ts b/.agent/src/__tests__/orchestrate-handoff-cli.test.ts index a7656baa..8129bf8d 100644 --- a/.agent/src/__tests__/orchestrate-handoff-cli.test.ts +++ b/.agent/src/__tests__/orchestrate-handoff-cli.test.ts @@ -1095,6 +1095,50 @@ test("review HUMAN_DECISION dispatches self-approval with source fields", () => assert.equal(inputs.source_recommended_next_step, "HUMAN_DECISION"); }); +test("review NO_AUTOMATED_ACTION dispatches self-approval when no branch work remains", () => { + const run = runOrchestrateHandoff({ + SOURCE_ACTION: "review", + SOURCE_CONCLUSION: "MINOR_ISSUES", + SOURCE_RECOMMENDED_NEXT_STEP: "NO_AUTOMATED_ACTION", + SOURCE_REQUIRED_BRANCH_WORK: "false", + TARGET_KIND: "pull_request", + TARGET_NUMBER: "128", + AUTOMATION_CURRENT_ROUND: "2", + AUTOMATION_MAX_ROUNDS: "5", + AGENT_ALLOW_SELF_APPROVE: "true", + }); + + assert.equal(run.status, 0, run.stderr || run.stdout); + assert.equal(run.outputs.get("decision"), "dispatch"); + assert.equal(run.outputs.get("next_action"), "agent-self-approve"); + assert.match(run.outputs.get("reason") || "", /no required branch-change work/); + assert.match(run.ghLog, /actions\/workflows\/agent-self-approve\.yml\/dispatches/); + const inputs = run.dispatchPayload?.inputs as Record; + assert.equal(inputs.pr_number, "128"); + assert.equal(inputs.source_conclusion, "MINOR_ISSUES"); + assert.equal(inputs.source_recommended_next_step, "NO_AUTOMATED_ACTION"); +}); + +test("review NO_AUTOMATED_ACTION stops when self-approval is disabled", () => { + const run = runOrchestrateHandoff({ + SOURCE_ACTION: "review", + SOURCE_CONCLUSION: "MINOR_ISSUES", + SOURCE_RECOMMENDED_NEXT_STEP: "NO_AUTOMATED_ACTION", + SOURCE_REQUIRED_BRANCH_WORK: "false", + TARGET_KIND: "pull_request", + TARGET_NUMBER: "128", + AUTOMATION_CURRENT_ROUND: "2", + AUTOMATION_MAX_ROUNDS: "5", + AGENT_ALLOW_SELF_APPROVE: "false", + }); + + assert.equal(run.status, 0, run.stderr || run.stdout); + assert.equal(run.outputs.get("decision"), "stop"); + assert.match(run.outputs.get("reason") || "", /self-approval disabled/); + assert.doesNotMatch(run.ghLog, /actions\/workflows\/agent-self-approve\.yml\/dispatches/); + assert.equal(run.dispatchPayload, null); +}); + test("review SHIP stops when self-approval is disabled", () => { const run = runOrchestrateHandoff({ SOURCE_ACTION: "review", diff --git a/.agent/src/__tests__/prepare-self-approve-cli.test.ts b/.agent/src/__tests__/prepare-self-approve-cli.test.ts index c95debcb..bda02be6 100644 --- a/.agent/src/__tests__/prepare-self-approve-cli.test.ts +++ b/.agent/src/__tests__/prepare-self-approve-cli.test.ts @@ -295,3 +295,83 @@ exit 1 rmSync(tempDir, { recursive: true, force: true }); } }); + +test("prepare-self-approve runs no-required-branch-work gate", () => { + const tempDir = mkdtempSync(join(tmpdir(), "agent-self-approve-prepare-")); + try { + const logPath = join(tempDir, "gh.log"); + writeFileSync(join(tempDir, "gh"), `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$FAKE_GH_LOG" +if [ "$1" = "pr" ] && [ "$2" = "view" ]; then + printf '{"author":{"login":"lolipopshock"},"headRefName":"agent/test","headRefOid":"abc123","isCrossRepository":false,"state":"OPEN"}\\n' + exit 0 +fi +if [ "$1" = "api" ] && [ "$2" = "graphql" ]; then + printf '{"data":{"viewer":{"login":"sepo-agent-app"}}}\\n' + exit 0 +fi +if [ "$1" = "api" ] && [ "$2" = "--paginate" ] && [ "$3" = "--slurp" ]; then + printf '%s\\n' '[[{"id":123,"body":"## AI Review Synthesis\\n\\n\\n\\n## Recommended Next Step\\nNO_AUTOMATED_ACTION\\n\\n## Final Verdict\\nMINOR_ISSUES\\n\\n## Action Items\\n- [ ] No required branch-change work remains.","created_at":"2026-05-07T10:00:00Z","user":{"login":"sepo-agent-app"}}]]' + exit 0 +fi +printf 'unexpected gh args: %s\\n' "$*" >&2 +exit 1 +`, { encoding: "utf8", mode: 0o755 }); + + const result = runPrepareSelfApprove({ + PATH: `${tempDir}:${process.env.PATH || ""}`, + AGENT_ALLOW_SELF_APPROVE: "true", + FAKE_GH_LOG: logPath, + GITHUB_REPOSITORY: "self-evolving/repo", + SOURCE_RECOMMENDED_NEXT_STEP: "NO_AUTOMATED_ACTION", + TARGET_KIND: "pull_request", + TARGET_NUMBER: "42", + }, tempDir); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.output, /should_run<<[^\n]+\ntrue/); + assert.match(result.output, /head_sha<<[^\n]+\nabc123/); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("prepare-self-approve rejects no-action reviews with required branch work", () => { + const tempDir = mkdtempSync(join(tmpdir(), "agent-self-approve-prepare-")); + try { + const logPath = join(tempDir, "gh.log"); + writeFileSync(join(tempDir, "gh"), `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$FAKE_GH_LOG" +if [ "$1" = "pr" ] && [ "$2" = "view" ]; then + printf '{"author":{"login":"lolipopshock"},"headRefName":"agent/test","headRefOid":"abc123","isCrossRepository":false,"state":"OPEN"}\\n' + exit 0 +fi +if [ "$1" = "api" ] && [ "$2" = "graphql" ]; then + printf '{"data":{"viewer":{"login":"sepo-agent-app"}}}\\n' + exit 0 +fi +if [ "$1" = "api" ] && [ "$2" = "--paginate" ] && [ "$3" = "--slurp" ]; then + printf '%s\\n' '[[{"id":123,"body":"## AI Review Synthesis\\n\\n\\n\\n## Recommended Next Step\\nNO_AUTOMATED_ACTION\\n\\n## Final Verdict\\nMINOR_ISSUES\\n\\n## Action Items\\n- [ ] Add the missing provenance regression test.","created_at":"2026-05-07T10:00:00Z","user":{"login":"sepo-agent-app"}}]]' + exit 0 +fi +printf 'unexpected gh args: %s\\n' "$*" >&2 +exit 1 +`, { encoding: "utf8", mode: 0o755 }); + + const result = runPrepareSelfApprove({ + PATH: `${tempDir}:${process.env.PATH || ""}`, + AGENT_ALLOW_SELF_APPROVE: "true", + FAKE_GH_LOG: logPath, + GITHUB_REPOSITORY: "self-evolving/repo", + SOURCE_RECOMMENDED_NEXT_STEP: "NO_AUTOMATED_ACTION", + TARGET_KIND: "pull_request", + TARGET_NUMBER: "42", + }, tempDir); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.output, /should_run<<[^\n]+\nfalse/); + assert.match(result.output, /did not confirm no required branch-change work/); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/.agent/src/__tests__/resolve-self-approve-cli.test.ts b/.agent/src/__tests__/resolve-self-approve-cli.test.ts index bf662170..7140a52f 100644 --- a/.agent/src/__tests__/resolve-self-approve-cli.test.ts +++ b/.agent/src/__tests__/resolve-self-approve-cli.test.ts @@ -50,7 +50,7 @@ if [ "$1" = "pr" ] && [ "$2" = "view" ]; then exit 0 fi if [ "$1" = "api" ] && [ "$2" = "--paginate" ] && [ "$3" = "--slurp" ]; then - printf '${commentsPayload}\\n' + printf '%s\\n' '${commentsPayload}' exit 0 fi if [ "$1" = "api" ] && [ "$2" = "graphql" ]; then @@ -212,6 +212,43 @@ test("resolve-self-approve accepts trusted human-decision provenance", () => { } }); +test("resolve-self-approve accepts trusted no-required-branch-work provenance", () => { + const tempDir = mkdtempSync(join(tmpdir(), "agent-self-approve-cli-")); + try { + writeFakeGh(tempDir, "abc123", { + synthesisBody: [ + "## AI Review Synthesis", + "", + "", + "", + "## Recommended Next Step", + "NO_AUTOMATED_ACTION", + "", + "## Final Verdict", + "MINOR_ISSUES", + "", + "## Action Items", + "- [ ] No required branch-change work remains.", + ].join("\n"), + }); + + const result = runResolveSelfApprove(tempDir, JSON.stringify({ + verdict: "APPROVE", + reason: "No required branch work remains.", + inspected_head_sha: "abc123", + }), { + SOURCE_RECOMMENDED_NEXT_STEP: "NO_AUTOMATED_ACTION", + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.output, /approved<<[^\n]+\ntrue/); + assert.match(result.output, /conclusion<<[^\n]+\napproved/); + assert.match(result.log, /^api --method POST repos\/self-evolving\/repo\/pulls\/42\/reviews /m); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + test("resolve-self-approve does not submit approval after head changes", () => { const tempDir = mkdtempSync(join(tmpdir(), "agent-self-approve-cli-")); try { diff --git a/.agent/src/__tests__/self-approval.test.ts b/.agent/src/__tests__/self-approval.test.ts index d792db55..6290b46d 100644 --- a/.agent/src/__tests__/self-approval.test.ts +++ b/.agent/src/__tests__/self-approval.test.ts @@ -357,6 +357,92 @@ test("evaluateSelfApprovalProvenance can allow trusted HUMAN_DECISION gate", () assert.match(fixPr.reason, /not SHIP/); }); +test("evaluateSelfApprovalProvenance can allow no-required-branch-work gate", () => { + const trusted = evaluateSelfApprovalProvenance({ + trustedActorLogin: "sepo-agent-app[bot]", + expectedHeadSha: "abc123", + allowNoBranchWorkGate: true, + comments: [ + { + authorLogin: "sepo-agent-app", + createdAt: "2026-05-07T10:00:00Z", + body: [ + "## AI Review Synthesis", + "", + "", + "", + "## Recommended Next Step", + "NO_AUTOMATED_ACTION: no unresolved branch work remains.", + "", + "## Final Verdict", + "MINOR_ISSUES", + "", + "## Action Items", + "- [ ] No required branch-change work remains.", + ].join("\n"), + }, + ], + }); + assert.equal(trusted.trusted, true); + assert.match(trusted.reason, /no required branch-change work/); + + const requiredWork = evaluateSelfApprovalProvenance({ + trustedActorLogin: "sepo-agent-app[bot]", + expectedHeadSha: "abc123", + allowNoBranchWorkGate: true, + comments: [ + { + authorLogin: "sepo-agent-app", + createdAt: "2026-05-07T10:00:00Z", + body: [ + "## AI Review Synthesis", + "", + "", + "", + "## Recommended Next Step", + "NO_AUTOMATED_ACTION", + "", + "## Final Verdict", + "MINOR_ISSUES", + "", + "## Action Items", + "- [ ] Add the missing provenance regression test.", + ].join("\n"), + }, + ], + }); + assert.equal(requiredWork.trusted, false); + assert.match(requiredWork.reason, /did not confirm no required branch-change work/); + + const blocking = evaluateSelfApprovalProvenance({ + trustedActorLogin: "sepo-agent-app[bot]", + expectedHeadSha: "abc123", + allowNoBranchWorkGate: true, + comments: [ + { + authorLogin: "sepo-agent-app", + createdAt: "2026-05-07T10:00:00Z", + body: [ + "## AI Review Synthesis", + "", + "", + "", + "## Recommended Next Step", + "NO_AUTOMATED_ACTION", + "", + "## Final Verdict", + "NEEDS_REWORK", + "", + "## Action Items", + "- [ ] No required branch-change work remains.", + ].join("\n"), + }, + ], + }); + assert.equal(blocking.trusted, false); + assert.match(blocking.reason, /not SHIP/); +}); + test("evaluateSelfApprovalProvenance requires review synthesis for the current head", () => { const stale = evaluateSelfApprovalProvenance({ trustedActorLogin: "sepo-agent-app[bot]", diff --git a/.agent/src/cli/dispatch-agent-orchestrator.ts b/.agent/src/cli/dispatch-agent-orchestrator.ts index 2ed93a5b..ccf6a837 100644 --- a/.agent/src/cli/dispatch-agent-orchestrator.ts +++ b/.agent/src/cli/dispatch-agent-orchestrator.ts @@ -4,16 +4,19 @@ // REQUESTED_BY, REQUEST_TEXT, AUTOMATION_CURRENT_ROUND, // AUTOMATION_MAX_ROUNDS, SESSION_BUNDLE_MODE, SOURCE_RUN_ID, TARGET_KIND, // AUTHOR_ASSOCIATION, ACCESS_POLICY, REPOSITORY_PRIVATE, ORCHESTRATION_ENABLED, -// SOURCE_RECOMMENDED_NEXT_STEP, SOURCE_HANDOFF_CONTEXT, BASE_BRANCH, BASE_PR +// SOURCE_RECOMMENDED_NEXT_STEP, SOURCE_REQUIRED_BRANCH_WORK, +// SOURCE_HANDOFF_CONTEXT, BASE_BRANCH, BASE_PR import { readFileSync } from "node:fs"; import { dispatchWorkflow } from "../github.js"; import { automationModeAllowsHandoff, buildReviewFixPrHandoffContext, + deriveReviewRequiredBranchWork, extractReviewConclusion, extractReviewRecommendedNextStep, normalizeConclusion, + normalizeRequiredBranchWork, normalizeRecommendedNextStep, } from "../handoff.js"; @@ -27,9 +30,21 @@ function readResponseFile(): string { } } -function sourceReviewNeedsFixPr(sourceAction: string, sourceConclusion: string, recommendedNextStep: string): boolean { +function sourceReviewNeedsFixPr( + sourceAction: string, + sourceConclusion: string, + recommendedNextStep: string, + requiredBranchWork: string, +): boolean { if (sourceAction.trim().toLowerCase() !== "review") return false; - if (normalizeRecommendedNextStep(recommendedNextStep) === "human_decision") return false; + const normalizedRecommendedNextStep = normalizeRecommendedNextStep(recommendedNextStep); + if (normalizedRecommendedNextStep === "human_decision") return false; + if ( + normalizedRecommendedNextStep === "no_automated_action" && + normalizeRequiredBranchWork(requiredBranchWork) !== "true" + ) { + return false; + } return new Set(["minor_issues", "needs_rework", "changes_requested"]).has(normalizeConclusion(sourceConclusion)); } @@ -38,6 +53,11 @@ function sourceReviewRecommendedNextStep(sourceAction: string, rawResponse: stri return extractReviewRecommendedNextStep(rawResponse); } +function sourceReviewRequiredBranchWork(sourceAction: string, rawResponse: string): string { + if (sourceAction.trim().toLowerCase() !== "review") return ""; + return deriveReviewRequiredBranchWork(rawResponse); +} + const automationMode = process.env.AUTOMATION_MODE || "disabled"; const sourceAction = process.env.SOURCE_ACTION || ""; const isManualOrchestrateStart = sourceAction.trim().toLowerCase() === "orchestrate"; @@ -57,8 +77,11 @@ const sourceConclusion = process.env.SOURCE_CONCLUSION || extractReviewConclusio const sourceRecommendedNextStep = normalizeRecommendedNextStep( process.env.SOURCE_RECOMMENDED_NEXT_STEP || sourceReviewRecommendedNextStep(sourceAction, rawResponse), ); +const sourceRequiredBranchWork = normalizeRequiredBranchWork( + process.env.SOURCE_REQUIRED_BRANCH_WORK || sourceReviewRequiredBranchWork(sourceAction, rawResponse), +); const sourceHandoffContext = process.env.SOURCE_HANDOFF_CONTEXT || - (sourceReviewNeedsFixPr(sourceAction, sourceConclusion, sourceRecommendedNextStep) + (sourceReviewNeedsFixPr(sourceAction, sourceConclusion, sourceRecommendedNextStep, sourceRequiredBranchWork) ? buildReviewFixPrHandoffContext(rawResponse) : ""); const targetNumber = process.env.TARGET_NUMBER || ""; @@ -76,6 +99,7 @@ dispatchWorkflow(repo, "agent-orchestrator.yml", ref, { source_action: sourceAction, source_conclusion: sourceConclusion, source_recommended_next_step: sourceRecommendedNextStep, + source_required_branch_work: sourceRequiredBranchWork, source_run_id: process.env.SOURCE_RUN_ID || process.env.GITHUB_RUN_ID || "", target_kind: targetKind, target_number: targetNumber, diff --git a/.agent/src/cli/orchestrate-handoff.ts b/.agent/src/cli/orchestrate-handoff.ts index 845b3208..ba97d8e5 100644 --- a/.agent/src/cli/orchestrate-handoff.ts +++ b/.agent/src/cli/orchestrate-handoff.ts @@ -4,7 +4,7 @@ // GITHUB_REPOSITORY, DEFAULT_BRANCH, REQUESTED_BY, REQUEST_TEXT, // SESSION_BUNDLE_MODE, SOURCE_RUN_ID, PLANNER_RESPONSE_FILE, TARGET_KIND, // BASE_BRANCH, BASE_PR, AGENT_COLLAPSE_OLD_REVIEWS, AGENT_ALLOW_SELF_APPROVE, -// AGENT_ALLOW_SELF_MERGE +// AGENT_ALLOW_SELF_MERGE, SOURCE_REQUIRED_BRANCH_WORK import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -857,6 +857,7 @@ const sourceAction = process.env.SOURCE_ACTION || ""; const sourceConclusion = process.env.SOURCE_CONCLUSION || "unknown"; const sourceRunId = process.env.SOURCE_RUN_ID || process.env.GITHUB_RUN_ID || ""; const sourceRecommendedNextStep = process.env.SOURCE_RECOMMENDED_NEXT_STEP || ""; +const sourceRequiredBranchWork = process.env.SOURCE_REQUIRED_BRANCH_WORK || ""; const sourceHandoffContext = process.env.SOURCE_HANDOFF_CONTEXT || ""; const sourceTargetKind = process.env.TARGET_KIND || ""; const sourceAssociationRaw = process.env.AUTHOR_ASSOCIATION || ""; @@ -1332,6 +1333,7 @@ function decidePlannerOrchestration(): HandoffDecision { sourceAction, sourceConclusion, sourceRecommendedNextStep, + sourceRequiredBranchWork, targetKind: sourceTargetKind, targetNumber, nextTargetNumber: process.env.NEXT_TARGET_NUMBER || "", @@ -1369,6 +1371,7 @@ const routeDecision = authorizationStop || (normalizeToken(sourceAction) === "or sourceAction, sourceConclusion, sourceRecommendedNextStep, + sourceRequiredBranchWork, targetKind: sourceTargetKind, targetNumber, nextTargetNumber: process.env.NEXT_TARGET_NUMBER || "", diff --git a/.agent/src/cli/prepare-self-approve.ts b/.agent/src/cli/prepare-self-approve.ts index 45df3cd2..5a72dddf 100644 --- a/.agent/src/cli/prepare-self-approve.ts +++ b/.agent/src/cli/prepare-self-approve.ts @@ -51,6 +51,7 @@ const allowSelfMerge = envFlagEnabled(process.env.AGENT_ALLOW_SELF_MERGE); const allowSameActorSelfApprove = allowSelfApprove && allowSelfMerge; const sourceRecommendedNextStep = normalizeToken(process.env.SOURCE_RECOMMENDED_NEXT_STEP || ""); const isHumanDecisionGate = sourceRecommendedNextStep === "human_decision"; +const isNoBranchWorkGate = sourceRecommendedNextStep === "no_automated_action"; if (!allowSelfApprove) { stop("AGENT_ALLOW_SELF_APPROVE is not enabled"); @@ -104,6 +105,7 @@ if (!allowSelfApprove) { trustedActorLogin: authenticatedActorLogin, expectedHeadSha: headSha, allowHumanDecisionGate: isHumanDecisionGate, + allowNoBranchWorkGate: isNoBranchWorkGate, }); if (!provenance.trusted) { stop(provenance.reason); diff --git a/.agent/src/cli/resolve-self-approve.ts b/.agent/src/cli/resolve-self-approve.ts index 542c31f1..b1887c74 100644 --- a/.agent/src/cli/resolve-self-approve.ts +++ b/.agent/src/cli/resolve-self-approve.ts @@ -76,6 +76,7 @@ const allowSelfMerge = envFlagEnabled(process.env.AGENT_ALLOW_SELF_MERGE); const allowSameActorSelfApprove = allowSelfApprove && allowSelfMerge; const sourceRecommendedNextStep = normalizeToken(process.env.SOURCE_RECOMMENDED_NEXT_STEP || ""); const isHumanDecisionGate = sourceRecommendedNextStep === "human_decision"; +const isNoBranchWorkGate = sourceRecommendedNextStep === "no_automated_action"; const decision = parseSelfApprovalDecision(readResponse()); let prState = ""; @@ -118,6 +119,7 @@ if (allowSelfApprove && normalizeToken(targetKind) === "pull_request" && repo && trustedActorLogin, expectedHeadSha, allowHumanDecisionGate: isHumanDecisionGate, + allowNoBranchWorkGate: isNoBranchWorkGate, }); approvalProvenanceTrusted = provenance.trusted; approvalProvenanceReason = provenance.reason; diff --git a/.agent/src/handoff.ts b/.agent/src/handoff.ts index 75c0873c..96cf1323 100644 --- a/.agent/src/handoff.ts +++ b/.agent/src/handoff.ts @@ -11,6 +11,7 @@ export interface HandoffInput { sourceAction: string; sourceConclusion: string; sourceRecommendedNextStep?: string; + sourceRequiredBranchWork?: string; sourceHandoffContext?: string; targetKind?: string; targetNumber: string; @@ -86,6 +87,11 @@ const DEFAULT_SELF_APPROVAL_FIX_PR_HANDOFF_CONTEXT = [ "Address only the self-approval REQUEST_CHANGES findings.", "Preserve the reviewed-head and deterministic approval safeguards; avoid unrelated changes.", ].join(" "); +const NO_REQUIRED_BRANCH_WORK_PATTERNS = [ + /^no (?:unresolved )?(?:required )?(?:actionable )?(?:branch(?:[- ]change)? )?(?:work|issues|findings|action items)(?: remains)?$/, + /^no (?:branch(?:[- ]change)? )?(?:work|changes?) (?:is |are )?required$/, + /^no required branch(?:[- ]change)? work remains$/, +]; const ANY_HANDOFF_MARKER_RE = new RegExp( ``, "i", @@ -140,6 +146,17 @@ export function normalizeRecommendedNextStep(value: string): string { return normalized; } +export function normalizeRequiredBranchWork(value: string): "true" | "false" | "unknown" { + const normalized = normalizeToken(value); + if (["true", "1", "yes", "on", "required", "has_required_branch_work"].includes(normalized)) { + return "true"; + } + if (["false", "0", "no", "off", "none", "no_required_branch_work"].includes(normalized)) { + return "false"; + } + return "unknown"; +} + export function formatMarkdownTableCell(value: string | number): string { return String(value) .replace(/\r?\n/g, " ") @@ -182,6 +199,19 @@ function normalizeReviewActionItem(line: string): string { .trim(); } +function normalizeReviewActionItemForClassification(line: string): string { + return normalizeReviewActionItem(line) + .replace(/[`*_]/g, "") + .replace(/[.!?:;]+$/g, "") + .toLowerCase(); +} + +export function isNoRequiredBranchWorkActionItem(line: string): boolean { + const normalized = normalizeReviewActionItemForClassification(line); + if (!normalized) return false; + return NO_REQUIRED_BRANCH_WORK_PATTERNS.some((pattern) => pattern.test(normalized)); +} + export function extractReviewActionItems(markdown: string): string[] { const section = extractMarkdownSection(markdown, "Action Items"); if (!section) return []; @@ -203,8 +233,18 @@ export function extractReviewActionItems(markdown: string): string[] { return items; } +export function extractRequiredReviewActionItems(markdown: string): string[] { + return extractReviewActionItems(markdown).filter((item) => !isNoRequiredBranchWorkActionItem(item)); +} + +export function deriveReviewRequiredBranchWork(markdown: string): "true" | "false" | "unknown" { + const section = extractMarkdownSection(markdown, "Action Items"); + if (!section) return "unknown"; + return extractRequiredReviewActionItems(markdown).length ? "true" : "false"; +} + export function buildReviewFixPrHandoffContext(markdown: string): string { - const items = extractReviewActionItems(markdown).slice(0, 5); + const items = extractRequiredReviewActionItems(markdown).slice(0, 5); if (!items.length) return defaultFixPrHandoffContext(); return [ "Address only the latest review synthesis action items:", @@ -476,6 +516,7 @@ function decideHeuristicHandoff(input: HandoffInput): HandoffDecision { if (sourceAction === "review") { const recommendedNextStep = normalizeRecommendedNextStep(input.sourceRecommendedNextStep || ""); + const requiredBranchWork = normalizeRequiredBranchWork(input.sourceRequiredBranchWork || ""); if (recommendedNextStep === "human_decision") { if (input.allowSelfApprove) { return { @@ -500,6 +541,39 @@ function decideHeuristicHandoff(input: HandoffInput): HandoffDecision { } return { decision: "stop", reason: "review verdict is SHIP", nextRound }; } + if (recommendedNextStep === "no_automated_action") { + if (conclusion === "minor_issues" && requiredBranchWork === "false") { + if (input.allowSelfApprove) { + return { + decision: "dispatch", + nextAction: "agent-self-approve", + targetNumber: nextTarget, + reason: "review found no required branch-change work after MINOR_ISSUES; dispatching agent-self-approve", + nextRound, + }; + } + return { + decision: "stop", + reason: "review found no required branch-change work after MINOR_ISSUES; self-approval disabled", + nextRound, + }; + } + if (requiredBranchWork === "true" && REVIEW_TO_FIX_PR.has(conclusion)) { + return { + decision: "dispatch", + nextAction: "fix-pr", + targetNumber: nextTarget, + reason: `review action items require branch changes after ${conclusion}; dispatching fix-pr`, + nextRound, + handoffContext: resolveFixPrHandoffContext(input), + }; + } + return { + decision: "stop", + reason: `review recommended NO_AUTOMATED_ACTION after ${conclusion}`, + nextRound, + }; + } if (REVIEW_TO_FIX_PR.has(conclusion)) { return { decision: "dispatch", diff --git a/.agent/src/run.ts b/.agent/src/run.ts index c5e8f96a..f6ec52e7 100644 --- a/.agent/src/run.ts +++ b/.agent/src/run.ts @@ -83,6 +83,7 @@ const SUPPLEMENTAL_PROMPT_VAR_NAMES = [ "ORCHESTRATOR_SOURCE_ACTION", "ORCHESTRATOR_SOURCE_CONCLUSION", "ORCHESTRATOR_SOURCE_RECOMMENDED_NEXT_STEP", + "ORCHESTRATOR_SOURCE_REQUIRED_BRANCH_WORK", "ORCHESTRATOR_SOURCE_RUN_ID", "ORCHESTRATOR_NEXT_TARGET_NUMBER", "ORCHESTRATOR_SOURCE_HANDOFF_CONTEXT", diff --git a/.agent/src/self-approval.ts b/.agent/src/self-approval.ts index 4f309ea7..e79340b1 100644 --- a/.agent/src/self-approval.ts +++ b/.agent/src/self-approval.ts @@ -1,4 +1,8 @@ -import { extractReviewConclusion, extractReviewRecommendedNextStep } from "./handoff.js"; +import { + deriveReviewRequiredBranchWork, + extractReviewConclusion, + extractReviewRecommendedNextStep, +} from "./handoff.js"; import { extractJsonObject } from "./response.js"; import { extractReviewSynthesisHeadSha, @@ -137,6 +141,7 @@ export function evaluateSelfApprovalProvenance(input: { trustedActorLogin: string; expectedHeadSha: string; allowHumanDecisionGate?: boolean; + allowNoBranchWorkGate?: boolean; }): SelfApprovalProvenanceResult { const trustedActor = normalizeActorLogin(input.trustedActorLogin); const expectedHeadSha = String(input.expectedHeadSha || "").trim(); @@ -166,6 +171,7 @@ export function evaluateSelfApprovalProvenance(input: { createdAtMs: createdAtMs(comment.createdAt), conclusion: extractReviewConclusion(body), recommendedNextStep: extractReviewRecommendedNextStep(body), + requiredBranchWork: deriveReviewRequiredBranchWork(body), reviewedHeadSha: extractReviewSynthesisHeadSha(body), }; }) @@ -174,6 +180,7 @@ export function evaluateSelfApprovalProvenance(input: { createdAtMs: number; conclusion: string; recommendedNextStep: string; + requiredBranchWork: "true" | "false" | "unknown"; reviewedHeadSha: string; } => Boolean(signal)) .sort((left, right) => left.createdAtMs - right.createdAtMs || left.index - right.index); @@ -213,6 +220,27 @@ export function evaluateSelfApprovalProvenance(input: { reason: `latest trusted review synthesis recommended HUMAN_DECISION after ${conclusion} for current head`, }; } + if ( + input.allowNoBranchWorkGate && + conclusion === "minor_issues" && + recommendedNextStep === "no_automated_action" && + latest.requiredBranchWork === "false" + ) { + return { + trusted: true, + reason: "latest trusted review synthesis found no required branch-change work after MINOR_ISSUES for current head", + }; + } + if ( + input.allowNoBranchWorkGate && + conclusion === "minor_issues" && + recommendedNextStep === "no_automated_action" + ) { + return { + trusted: false, + reason: "latest trusted review synthesis did not confirm no required branch-change work remains", + }; + } return { trusted: false, diff --git a/.github/prompts/agent-orchestrator.md b/.github/prompts/agent-orchestrator.md index d3760a8c..e0be5bd8 100644 --- a/.github/prompts/agent-orchestrator.md +++ b/.github/prompts/agent-orchestrator.md @@ -8,6 +8,7 @@ chain should stop or hand off to exactly one allowed next action. - Source action: `${ORCHESTRATOR_SOURCE_ACTION}` - Source conclusion: `${ORCHESTRATOR_SOURCE_CONCLUSION}` - Source recommended next step: `${ORCHESTRATOR_SOURCE_RECOMMENDED_NEXT_STEP}` +- Source required branch-change work: `${ORCHESTRATOR_SOURCE_REQUIRED_BRANCH_WORK}` - Source run ID: `${ORCHESTRATOR_SOURCE_RUN_ID}` - Current round: `${ORCHESTRATOR_CURRENT_ROUND}` - Max rounds: `${ORCHESTRATOR_MAX_ROUNDS}` @@ -27,10 +28,13 @@ these policy rules: produced a pull request target. - `review` may hand off to `agent-self-approve` when self-approval is enabled and either the verdict is `SHIP` or the source recommended next step is - `HUMAN_DECISION`. + `HUMAN_DECISION`, or when the verdict is `MINOR_ISSUES`, the source + recommended next step is `NO_AUTOMATED_ACTION`, and the source reports no + required branch-change work. - `review` may hand off to `fix-pr` only for `MINOR_ISSUES`, `NEEDS_REWORK`, or `CHANGES_REQUESTED` when the source recommended next step - is not `HUMAN_DECISION`. + is not `HUMAN_DECISION` and either the source did not recommend + `NO_AUTOMATED_ACTION` or it still reports required branch-change work. - `agent-self-approve` may hand off to `fix-pr` only for `REQUEST_CHANGES`. `APPROVED` may hand off to `agent-self-merge` only when self-merge is enabled; otherwise `APPROVED`, `BLOCKED`, and `FAILED` stop. @@ -59,8 +63,8 @@ these policy rules: `blocked` when no follow-up workflow should run. - Duplicate handoffs are skipped by the orchestrator marker dedupe logic. - You may choose to stop when another automatic action is not useful, except - that enabled self-approval should receive `SHIP` and review `HUMAN_DECISION` - handoffs. + that enabled self-approval should receive `SHIP`, review `HUMAN_DECISION`, + and eligible no-required-branch-work `NO_AUTOMATED_ACTION` handoffs. ## Instructions @@ -88,7 +92,10 @@ Rules: - If the latest review synthesis includes a `Recommended Next Step`, treat it as the primary automation signal: hand off on `FIX_PR`, hand off to `agent-self-approve` on `HUMAN_DECISION` when self-approval is enabled, and - stop on `HUMAN_DECISION` or `NO_AUTOMATED_ACTION` otherwise. + treat `NO_AUTOMATED_ACTION` as no more branch mutation/review loop. For + `MINOR_ISSUES` with no required branch-change work and self-approval + enabled, hand off to `agent-self-approve`; otherwise stop on + `NO_AUTOMATED_ACTION` unless required branch-change work remains. - Use `handoff` only when one more automatic action is clearly warranted. - For issue-level `orchestrate`, prefer `handoff` with `next_action: "implement"` when the requested work fits in the current issue. Use diff --git a/.github/prompts/agent-self-approve.md b/.github/prompts/agent-self-approve.md index 9146f1b3..9afeb888 100644 --- a/.github/prompts/agent-self-approve.md +++ b/.github/prompts/agent-self-approve.md @@ -25,12 +25,16 @@ If this run came from review handoff, the orchestrator also passed: - Source recommended next step: `${SELF_APPROVE_SOURCE_RECOMMENDED_NEXT_STEP}` For `HUMAN_DECISION` review handoffs, make the decision here instead of -routing back to a human by default. Use `APPROVE` only when the trusted -current-head review verdict is `SHIP`, or when a current-head review synthesis -explicitly recommended `HUMAN_DECISION` and you judge the remaining concerns to -be acceptable product/maintenance tradeoffs. For other non-`SHIP` verdicts, -return `REQUEST_CHANGES` when concrete follow-up is needed, or `BLOCKED` only -when safety checks, missing context, or automation limits prevent a reliable +routing back to a human by default. For `NO_AUTOMATED_ACTION` review handoffs, +the preflight has already verified a trusted current-head `MINOR_ISSUES` +synthesis with no required branch-change work; decide whether the remaining +minor concerns are acceptable. Use `APPROVE` only when the trusted current-head +review verdict is `SHIP`, when a current-head review synthesis explicitly +recommended `HUMAN_DECISION` and you judge the remaining concerns acceptable, +or when `NO_AUTOMATED_ACTION` means no branch work remains and self-governance +approval is appropriate. For other non-`SHIP` verdicts, return +`REQUEST_CHANGES` when concrete follow-up is needed, or `BLOCKED` only when +safety checks, missing context, or automation limits prevent a reliable decision. Rules: diff --git a/.github/workflows/agent-orchestrator.yml b/.github/workflows/agent-orchestrator.yml index e25ab5a5..8a9f54bb 100644 --- a/.github/workflows/agent-orchestrator.yml +++ b/.github/workflows/agent-orchestrator.yml @@ -13,6 +13,10 @@ on: description: "Optional source action recommended next step" required: false default: "" + source_required_branch_work: + description: "Whether the source review reported required branch-change work" + required: false + default: "" source_run_id: description: "Workflow run ID of the source action, used for handoff dedupe" required: false @@ -158,6 +162,7 @@ jobs: ORCHESTRATOR_SOURCE_ACTION: ${{ inputs.source_action }} ORCHESTRATOR_SOURCE_CONCLUSION: ${{ inputs.source_conclusion }} ORCHESTRATOR_SOURCE_RECOMMENDED_NEXT_STEP: ${{ inputs.source_recommended_next_step }} + ORCHESTRATOR_SOURCE_REQUIRED_BRANCH_WORK: ${{ inputs.source_required_branch_work }} ORCHESTRATOR_SOURCE_RUN_ID: ${{ inputs.source_run_id || github.run_id }} ORCHESTRATOR_NEXT_TARGET_NUMBER: ${{ inputs.next_target_number }} ORCHESTRATOR_SOURCE_HANDOFF_CONTEXT: ${{ inputs.source_handoff_context }} @@ -210,6 +215,7 @@ jobs: SOURCE_ACTION: ${{ inputs.source_action }} SOURCE_CONCLUSION: ${{ inputs.source_conclusion }} SOURCE_RECOMMENDED_NEXT_STEP: ${{ inputs.source_recommended_next_step }} + SOURCE_REQUIRED_BRANCH_WORK: ${{ inputs.source_required_branch_work }} SOURCE_HANDOFF_CONTEXT: ${{ inputs.source_handoff_context }} SOURCE_RUN_ID: ${{ inputs.source_run_id || github.run_id }} AGENT_ALLOW_SELF_APPROVE: ${{ vars.AGENT_ALLOW_SELF_APPROVE || 'false' }}